;;; elfeed-search.el --- list feed entries -*- lexical-binding: t; -*-

;; This is free and unencumbered software released into the public domain.

;; Author: Christopher Wellons <wellons@nullprogram.com>

;;; Commentary:

;; The `elfeed-search' buffer which lists feed entries and supports live
;; filtering.  Open the search buffer via the command `elfeed-search'.

;;; Code:

(eval-when-compile (require 'subr-x))
(require 'hl-line)
(require 'wid-edit) ; widget-inactive face

(require 'bookmark)
(bookmark-maybe-load-default-file)

(require 'elfeed)

(defgroup elfeed-search ()
  "Elfeed search buffer."
  :group 'elfeed)

(defvar-local elfeed-search-entries ()
  "List of the entries currently on display.")

(defvar elfeed-search-filter-history nil
  "Filter history for `completing-read'.")

(defvar elfeed-search--update-timer nil
  "Timer to debounce search buffer updates.")

(defvar elfeed-search--resize-timer nil
  "Timer to debounce search window resizing.")

(defvar elfeed-search--live-timer nil
  "Timer to debounce the live filter.")

(define-obsolete-variable-alias 'elfeed-search-last-update
  'elfeed-search--last-update "4.0.0")
(defvar elfeed-search--last-update 0
  "The last time the buffer was redrawn in epoch seconds.")

(defvar elfeed-search--last-width 0
  "The last window width.")

(defvar elfeed-search--marked nil
  "List of marked entries.")

(defvar elfeed-search--unread-count nil
  "Unread count in header line.")

(defvar elfeed-search-update-hook (list #'elfeed-search-add-separators)
  "List of functions to run immediately following a search buffer update.
The functions may modify the search buffer or add overlays, for example
`elfeed-search-add-separators'.")

(defcustom elfeed-search-confirm-tag 2
  "Confirm `elfeed-search-tag' when modifying this many or more entries."
  :type '(choice (const :tag "No confirmation" nil)
                 (const :tag "Always confirm" t)
                 natnum))

(defcustom elfeed-search-update-delay 1.0
  "Delay search buffer updates by that many seconds to reduce redraws.
This delay affects only the redraws after feed updates.  See also
`elfeed-search-live-delay' and `elfeed-search-resize-delay'."
  :type 'number)

(defcustom elfeed-search-live-delay 0.1
  "Delay search buffer live updates by that many seconds to reduce redraws.
This delay affects only the redraws during live filtering.  See also
`elfeed-search-update-delay' and `elfeed-search-resize-delay'."
  :type 'number)

(defcustom elfeed-search-resize-delay 0.1
  "Delay search buffer resizing by that many seconds to reduce redraws.
Set to nil to disable redraw on resize."
  :type '(choice (const nil) number))

(defcustom elfeed-search-filter "@6months +unread"
  "Query string filtering shown entries."
  :type 'string
  :local t)

(defcustom elfeed-search-max-entries 500
  "Number of entries which are maximally displayed."
  :type '(choice (const nil) natnum))

(defcustom elfeed-search-completion t
  "Enable tag and search filter completion."
  :type 'boolean)

(define-obsolete-variable-alias 'elfeed-sort-order
  'elfeed-search-sort-order "4.0.0")

(defcustom elfeed-search-sort-order 'descending
  "The order in which entries should be displayed.

Changing this from the default will lead to misleading results
during live filter editing, but the results be will correct when
live filter editing is exited."
  :type '(choice (const descending) (const ascending)))

(defcustom elfeed-search-sort-function nil
  "Sort predicate applied to the list of entries before display.

This function must take two entries as arguments, an interface
suitable as the predicate for `sort'.

Changing this from the default will lead to misleading results
during live filter editing, but the results be will correct when
live filter editing is exited.

The variable can also be set to a list of functions (or nil for the
default function) such that you can cycle between the function via the
command `elfeed-search-cycle-order'."
  :type `(choice
          (const :tag "Group by feed" ,#'elfeed-search-group-by-feed)
          (function :tag "Custom function")
          (const :tag "Default sorting" nil)
          (repeat :tag "List of functions" function)))

(defun elfeed-search--sort-function ()
  "Get sort function."
  (when-let* ((sort elfeed-search-sort-function))
    (if (functionp sort) sort (car sort))))

(defun elfeed-search-group-by-feed (a b)
  "Group entries A and B by feed."
  (unless (equal (elfeed-entry-feed-id a) (elfeed-entry-feed-id b))
    (let ((less (string-collate-lessp
                 (elfeed-meta--title (elfeed-entry-feed a))
                 (elfeed-meta--title (elfeed-entry-feed b))
                 nil t)))
      (if (eq elfeed-search-sort-order 'ascending)
          (not less)
        less))))

(defcustom elfeed-search-remain-on-entry nil
  "When non-nil, keep point at entry after performing a command.

When nil, always move to next entry after a command.  The variable can
be set to a list of symbols show, browse, tag, mark and yank, such that
point does not move after the listed operations.  Example:

  (setq elfeed-search-remain-on-entry \\='(browse show yank))"
  :type '(choice (const nil) (const t)
                 (repeat symbol)))

(defcustom elfeed-search-clipboard-type 'PRIMARY
  "Selects the clipboard `elfeed-search-yank' should use.
Choices are the symbols PRIMARY, SECONDARY, or CLIPBOARD."
  :type '(choice (const PRIMARY) (const SECONDARY) (const CLIPBOARD)))

(defcustom elfeed-search-date-format '("%Y-%m-%d" 10 :left)
  "The `format-time-string' format, target width, and alignment for dates.

This should be (string integer keyword) for (format width alignment).
Possible alignments are :left and :right."
  :type '(list string integer (choice (const :left) (const :right))))

(defcustom elfeed-search-separator-date-format "%b %Y"
  "The `format-time-string' format for date separators."
  :type '(choice (const nil) string))

(defcustom elfeed-search-compile-filter t
  "If non-nil, compile search filters into bytecode on the fly."
  :type 'boolean)

(defcustom elfeed-search-title-max-width 70
  "Maximum column width for titles in the `elfeed-search' buffer."
  :type 'integer)

(defcustom elfeed-search-title-min-width 16
  "Minimum column width for titles in the `elfeed-search' buffer."
  :type 'integer)

(defcustom elfeed-search-trailing-width 30
  "Space reserved for displaying the feed and tag information."
  :type 'integer)

(defcustom elfeed-search-face-alist
  '((unread elfeed-search-unread-title-face))
  "Mapping of tags to faces in the Elfeed entry listing."
  :type '(alist :key-type symbol :value-type (repeat face)))

(defvar elfeed-search-header-function #'elfeed-search--header
  "Function that returns the string to be used for the header line.")

(defvar elfeed-search-print-entry-function #'elfeed-search-print-entry--default
  "Function to print entries into the *elfeed-search* buffer.")

(define-obsolete-variable-alias 'elfeed-search-filter-active
  'elfeed-search--filter-active "4.0.0")
(defvar elfeed-search--filter-active nil
  "When non-nil, Elfeed is currently reading a filter from the minibuffer.
When live editing the filter, it is bound to :live.")

(defvar elfeed-search-filter-overflowing nil)
(make-obsolete-variable 'elfeed-search-filter-overflowing nil "4.0.0")

(defun elfeed-search-tag-unread ()
  "Add the `unread' tag to all selected entries.
With \\[universal-argument] pressed before, modify all entries of the list."
  (interactive nil elfeed-search-mode)
  (elfeed-search-tag 'unread))

(defun elfeed-search-untag-unread ()
  "Remove the `unread' tag from all selected entries.
With \\[universal-argument] pressed before, modify all entries of the list."
  (interactive nil elfeed-search-mode)
  (elfeed-search-untag 'unread))

(defun elfeed-search-last-entry ()
  "Place point on last entry."
  (interactive nil elfeed-search-mode)
  (goto-char (point-max))
  (redisplay) ;; Enforce displaying last line
  (forward-line -1)
  (when (get-text-property (point) 'elfeed-search--truncated)
    (forward-line -1))
  (when (use-region-p)
    (forward-line 1)))

(defun elfeed-search-first-entry ()
  "Place point on first entry."
  (interactive nil elfeed-search-mode)
  (goto-char (point-min)))

(defvar-keymap elfeed-search-mode-map
  :doc "Keymap for `elfeed-search-mode'."
  :parent special-mode-map
  "G" #'elfeed-search-fetch
  "RET" #'elfeed-search-show-entry
  "=" #'elfeed-search-feed-filter
  "~" #'elfeed-search-exclude-feed-filter
  "@" #'elfeed-search-date-filter
  "<elfeed-date>" #'elfeed-search-date-filter
  "<elfeed-entry>" #'elfeed-search-show-entry
  "<elfeed-feed>" #'elfeed-search-feed-filter
  "<elfeed-tag>" #'elfeed-search-tag-filter
  "o" #'elfeed-search-cycle-order
  "O" #'elfeed-search-reverse-order
  "s" #'elfeed-search-live-filter
  "S" #'elfeed-search-set-filter
  "c" #'elfeed-search-clear-filter
  "b" #'elfeed-search-browse-url
  "B" #'elfeed-search-browse-url-secondary
  "y" #'elfeed-search-yank
  "u" #'elfeed-search-tag-unread
  "r" #'elfeed-search-untag-unread
  "n" #'next-line
  "p" #'previous-line
  "m" #'elfeed-search-mark
  "M" #'elfeed-search-unmark
  "t" #'elfeed-search-set-entry-title
  "T" #'elfeed-search-set-feed-title
  "+" #'elfeed-search-tag
  "-" #'elfeed-search-untag
  "<" #'elfeed-search-first-entry
  ">" #'elfeed-search-last-entry
  "<remap> <beginning-of-buffer>" #'elfeed-search-first-entry
  "<remap> <end-of-buffer>" #'elfeed-search-last-entry
  "<remap> <backward-paragraph>" #'elfeed-search-previous-separator
  "<remap> <forward-paragraph>" #'elfeed-search-next-separator
  "P" #'elfeed-search-previous-separator
  "N" #'elfeed-search-next-separator)

(easy-menu-define elfeed-search-mode-menu elfeed-search-mode-map
  "Menu for `elfeed-search-mode'."
  '("Elfeed Search"
    ["Show entry" elfeed-search-show-entry]
    ["Browse entry" elfeed-search-browse-url]
    ["Browse secondary" elfeed-search-browse-url-secondary]
    ["Copy URL" elfeed-search-yank]
    "--"
    ["Add tag" elfeed-search-tag]
    ["Remove tag" elfeed-search-untag]
    ["Tag as unread" elfeed-search-tag-unread]
    ["Tag as read" elfeed-search-untag-unread]
    "--"
    ["Live filter" elfeed-search-live-filter]
    ("Filter"
     ["Current day only" elfeed-search-date-filter]
     ["Current feed only" elfeed-search-feed-filter]
     ["Set filter" elfeed-search-set-filter]
     ["Clear filter" elfeed-search-clear-filter])
    ("Sort order"
     ["Cycle" elfeed-search-cycle-order]
     ["Reverse" elfeed-search-reverse-order])
    "--"
    ["Fetch all" elfeed-search-fetch]
    ("Fetch"
     ["Fetch visible" elfeed-search-fetch-visible]
     ["Fetch feed" elfeed-update-feed])
    "--"
    ("Set title"
     ["Set feed title" elfeed-search-set-feed-title]
     ["Set entry title" elfeed-search-set-entry-title])
    "--"
    ("Maintenance"
     ["Apply auto tags" elfeed-apply-autotags-now]
     ["Apply hooks" elfeed-apply-hooks-now]
     ["Compact database" elfeed-db-compact]
     ["Unjam queue" elfeed-unjam]
     ["Show log" elfeed-log-show])
    "--"
    ["Revert buffer" revert-buffer]
    ["Quit window" quit-window]
    "--"
    ["Customize" (customize-group 'elfeed)]))

(defun elfeed-search--remain-on-entry-p (action)
  "Remain on current entry for ACTION?"
  (and elfeed-search-remain-on-entry
       (or (not (consp elfeed-search-remain-on-entry))
           (memq action elfeed-search-remain-on-entry))))

(defun elfeed-search--after-action (action)
  "Possibly move to next entry after ACTION.
Movement is configured by `elfeed-search-remain-on-entry'."
  ;; Ignore region for the `show' action, like the related commands, which also
  ;; ignore the region and show the entry at point only.
  (when (and (or (eq action 'show) (not (use-region-p)))
             (or (eq action 'mark) (not elfeed-search--marked))
             (not (elfeed-search--remain-on-entry-p action)))
    (forward-line)))

(defun elfeed-search--unread-count ()
  "Count the number of entries and feeds being currently displayed."
  (with-memoization elfeed-search--unread-count
    (cl-loop with feeds = (make-hash-table :test #'eq)
             for entry in elfeed-search-entries
             count entry into entry-count
             count (elfeed-tagged-p 'unread entry) into unread-count
             do (puthash (elfeed-entry-feed entry) t feeds)
             finally return (elfeed--header-unread-count
                             unread-count entry-count (hash-table-count feeds)
                             #'elfeed-search-fetch-visible))))

(defun elfeed-search--header ()
  "Computes the string to be used as the header line."
  (or
   (elfeed--header-jobs)
   (let ((filter (when (and (not elfeed-search--filter-active)
                            (string-match-p "[^ ]" elfeed-search-filter))
                   (elfeed-add-properties
                    (mapconcat
                     (lambda (x)
                       (elfeed--header-button
                        (lambda ()
                          (interactive)
                          (elfeed-search--toggle-filter x))
                        x (format "Remove filter %s" x)))
                     (split-string elfeed-search-filter) " ")
                    'face 'elfeed-search-filter-face))))
     (concat (elfeed--header-update elfeed-search--last-update)
             ", " (elfeed-search--unread-count)
             (and filter ", ") filter))))

(define-derived-mode elfeed-search-mode special-mode "elfeed-search"
  "Major mode for listing elfeed feed entries."
  :syntax-table nil :abbrev-table nil :interactive nil
  (setq-local truncate-lines t
              mode-line-modified nil
              mode-line-mule-info nil
              mode-line-remote nil
              desktop-save-buffer #'elfeed-search-desktop-save
              bookmark-make-record-function #'elfeed-search-bookmark-make-record
              revert-buffer-function #'elfeed-search--update-force
              default-directory (elfeed-default-directory)
              hl-line-sticky-flag t)
  (elfeed--header-line-format 'elfeed-search-header-function)
  (buffer-disable-undo)
  (hl-line-mode)
  (add-hook 'elfeed-update-hook #'elfeed-search--update-debounce)
  (add-hook 'elfeed-update-init-hook #'elfeed-search--update-force)
  (add-hook 'window-size-change-functions #'elfeed-search--resize nil 'local)
  (elfeed-db--save-on-quit)
  (elfeed-search-update :force))

;;;###autoload
(defun elfeed-search (&optional new-filter)
  "Display `elfeed-search' buffer, optionally with a NEW-FILTER.
NEW-FILTER can be :live to start a live search."
  (interactive)
  (switch-to-buffer (elfeed-search-buffer))
  (unless (eq major-mode 'elfeed-search-mode)
    (elfeed-search-mode))
  (when new-filter
    (if (eq new-filter :live)
        (elfeed-search-live-filter)
      ;; Scroll to top when resetting the buffer
      (goto-char (point-min))
      (set-window-start nil (point-min))
      (elfeed-search-set-filter new-filter))))

(defun elfeed-search-buffer ()
  "Create and return search buffer."
  (get-buffer-create "*elfeed-search*"))

(defun elfeed-search-format-date (date)
  "Format DATE for printing in `elfeed-search-mode'.
The customization `elfeed-search-date-format' sets the formatting."
  (cl-destructuring-bind (format width align) elfeed-search-date-format
    (elfeed-format-column (format-time-string format (seconds-to-time date))
                          width align)))

(defgroup elfeed-search-faces ()
  "Elfeed search buffer faces."
  :group 'elfeed-search)

(defface elfeed-search-date-face
  '((((class color) (background light)) (:foreground "#aaa"))
    (((class color) (background dark))  (:foreground "#77a")))
  "Face used in search mode for dates.")

(defface elfeed-search-title-face
  '((((class color) (background light)) (:foreground "#000"))
    (((class color) (background dark))  (:foreground "#fff")))
  "Face used in search mode for titles.")

(defface elfeed-search-unread-title-face
  '((t :weight bold))
  "Face used in search mode for unread entry titles.")

(defface elfeed-search-feed-face
  '((((class color) (background light)) (:foreground "#aa0"))
    (((class color) (background dark))  (:foreground "#ff0")))
  "Face used in search mode for feed titles.")

(defface elfeed-search-tag-face
  '((((class color) (background light)) (:foreground "#070"))
    (((class color) (background dark))  (:foreground "#0f0")))
  "Face used in search mode for tags.")

(defface elfeed-search-last-update-face
  '((t))
  "Face for showing the date and time the database was last updated.")

(defface elfeed-search-unread-count-face
  '((((class color) (background light)) (:foreground "#000"))
    (((class color) (background dark))  (:foreground "#fff")))
  "Face used in search mode for the unread counter in the header.")

(defface elfeed-search-filter-face
  '((t :inherit mode-line-buffer-id))
  "Face for showing the current Elfeed search filter.")

(defface elfeed-search-marked-face
  '((t :inherit (lazy-highlight bold)))
  "Face for marked entries.")

(put 'elfeed-search-marked 'face 'elfeed-search-marked-face)

(defface elfeed-search-separator-face
  '((t :inherit (bold elfeed-search-date-face) :underline t :extend t))
  "Face for marked entries.")

(defun elfeed-search--faces (tags)
  "Return all the faces that apply to an entry with TAGS."
  (nconc (cl-loop for (tag . faces) in elfeed-search-face-alist
                  when (memq tag tags)
                  append faces)
         (list 'elfeed-search-title-face)))

(defun elfeed-search--column-date (entry)
  "Format the date column for ENTRY, return pair of string and width."
  (cons (elfeed-add-properties
         (elfeed-search-format-date (elfeed-entry-date entry))
         'face 'elfeed-search-date-face
         'mouse-face 'highlight
         'follow-link [elfeed-date])
        (cadr elfeed-search-date-format)))

(defun elfeed-search--column-title (entry)
  "Format the title column for ENTRY, return pair of string and width."
  (let* ((title (elfeed-meta--title entry))
         (title (if (or (not title) (equal title ""))
                    (elfeed-entry-link entry)
                  title))
         (title-faces (elfeed-search--faces (elfeed-entry-tags entry)))
         (window (get-buffer-window))
         (title-width (elfeed-clamp
                       elfeed-search-title-min-width
                       (- (if window (window-width window) (frame-width))
                          10 elfeed-search-trailing-width)
                       elfeed-search-title-max-width)))
    (cons (elfeed-add-properties
           (elfeed-format-column title title-width :left)
           'face title-faces
           'mouse-face 'highlight
           'follow-link [elfeed-entry])
          title-width)))

(defun elfeed-search--column-feed (entry)
  "Format the feed column for ENTRY, return string."
  (when-let* ((feed (elfeed-entry-feed entry))
              (title (elfeed-meta--title feed)))
    (propertize title 'face 'elfeed-search-feed-face
                'mouse-face 'highlight
                'follow-link [elfeed-feed])))

(defun elfeed-search--column-tags (entry)
  "Format the tags column for ENTRY, return string."
  (when-let* ((tags (elfeed-entry-tags entry)))
    (concat " ("
            (mapconcat
             (lambda (s)
               (propertize (symbol-name s)
                           'face 'elfeed-search-tag-face
                           'mouse-face 'highlight 'elfeed-tag s
                           'follow-link [elfeed-tag]))
             tags ",")
            ")")))

(defun elfeed-search-print-entry--default (entry)
  "Print ENTRY to the buffer."
  (pcase-let ((`(,date . ,date-width) (elfeed-search--column-date entry))
              (`(,title . ,title-width) (elfeed-search--column-title entry))
              (feed (elfeed-search--column-feed entry))
              (tags (elfeed-search--column-tags entry)))
    (insert date
            (propertize " " 'display `(space :align-to ,(1+ date-width)))
            title
            (if feed
                (propertize " " 'display
                            `( space :align-to
                               ,(+ 2 date-width title-width)))
              "")
            (or feed "") (or tags ""))))

(defun elfeed-search-parse-filter (filter)
  "Parse the elements of a search FILTER into a plist."
  (let ((must-have ())
        (must-not-have ())
        (before nil)
        (after nil)
        (matches ())
        (not-matches ())
        (limit nil)
        (feeds ())
        (not-feeds ()))
    (cl-loop for element in (split-string filter)
             for type = (aref element 0)
             do (cl-case type
                  (?+
                   (let ((symbol (intern (substring element 1))))
                     (unless (eq '## symbol)
                       (push symbol must-have))))
                  (?-
                   (let ((symbol (intern (substring element 1))))
                     (unless (eq '## symbol)
                       (push symbol must-not-have))))
                  (?@ (cl-multiple-value-bind (a b)
                          (split-string (substring element 1) "--")
                        (let ((duration-a (elfeed-time-duration a))
                              (duration-b (and b (elfeed-time-duration b))))
                          (when (and duration-b (> duration-b duration-a))
                            (cl-rotatef duration-a duration-b))
                          (when duration-b (setf before duration-b))
                          (setf after duration-a))))
                  (?! (let ((re (substring element 1)))
                        (when (elfeed-valid-regexp-p re)
                          (push re not-matches))))
                  (?# (setf limit (string-to-number (substring element 1))))
                  (?= (let ((re (substring element 1)))
                        (when (elfeed-valid-regexp-p re)
                          (push re feeds))))
                  (?~ (let ((re (substring element 1)))
                        (when (elfeed-valid-regexp-p re)
                          (push re not-feeds))))
                  (otherwise (when (elfeed-valid-regexp-p element)
                               (push element matches)))))
    `(,@(and before (list :before before))
      ,@(and after (list :after after))
      ,@(and must-have (list :must-have must-have))
      ,@(and must-not-have (list :must-not-have must-not-have))
      ,@(and matches (list :matches matches))
      ,@(and not-matches (list :not-matches not-matches))
      ,@(and limit (list :limit limit))
      ,@(and feeds (list :feeds feeds))
      ,@(and not-feeds (list :not-feeds not-feeds)))))

(defun elfeed-search--recover-time (seconds)
  "Pick a reasonable filter representation for SECONDS."
  (let ((units '((60   1 "min")
                 (60   1 "hour")
                 (24   1 "day")
                 (7    1 "week")
                 (30   7 "month")
                 (1461 120 "year")))
        (value (float seconds))
        (name "second"))
    (cl-loop for (n d unit) in units
             for next-value = (/ (* value d) n)
             when (< next-value 1.0)
             return t
             do (setf name unit
                      value next-value))
    (let ((count (format "%.4g" value)))
      (concat count name (if (equal count "1") "" "s")))))

(defun elfeed-search--recover-units (after-seconds &optional before-seconds)
  "Stringify the age or optionally a date range.
The date range is specified by AFTER-SECONDS and BEFORE-SECONDS."
  (apply 'concat "@"
          (elfeed-search--recover-time after-seconds)
          (when before-seconds
            (list "--"(elfeed-search--recover-time before-seconds)))))

(defun elfeed-search-unparse-filter (filter)
  "Inverse of `elfeed-search-parse-filter', turning FILTER into a string.

The time (@n-units-ago) filter may not exactly match the
original, but will be equal in its effect."
  (let ((output ()))
    (cl-destructuring-bind (&key after     before
                                 must-have must-not-have
                                 matches   not-matches
                                 feeds     not-feeds
                                 limit &allow-other-keys)
        filter
      (when after
        (push (elfeed-search--recover-units after before) output))
      (dolist (tag must-have)
        (push (format "+%S" tag) output))
      (dolist (tag must-not-have)
        (push (format "-%S" tag) output))
      (dolist (re matches)
        (push re output))
      (dolist (re not-matches)
        (push (concat "!" re) output))
      (when limit
        (push (format "#%d" limit) output))
      (dolist (feed feeds)
        (push (format "=%s" feed) output))
      (dolist (feed not-feeds)
        (push (format "~%s" feed) output))
      (string-join (nreverse output) " "))))

(defun elfeed-search-filter (filter entry feed &optional count now)
  "Return non-nil if ENTRY and FEED pass FILTER.

COUNT is the total number of entries collected so far, for filtering
against a limit filter (example: #10).  NOW is the current float time in
seconds.

See `elfeed-search-set-filter' for format/syntax documentation.
This function must *only* be called within the body of
`elfeed-db-visit' because it may perform a non-local exit."
  (cl-destructuring-bind (&key must-have must-not-have
                               matches   not-matches
                               feeds     not-feeds
                               after limit &allow-other-keys)
      filter
    (let* ((tags (elfeed-entry-tags entry))
           (date (elfeed-entry-date entry))
           (age (- (or now (float-time)) date))
           (title (elfeed-meta--title entry))
           (link (elfeed-entry-link entry))
           (feed-title (or (elfeed-meta--title feed) ""))
           (feed-id (elfeed-feed-id feed)))
      (when (or (and after (> age after))
                (and limit (<= limit 0))
                (and limit count (>= count limit)))
        (elfeed-db-return))
      (and (cl-every  (lambda (tag) (memq tag tags)) must-have)
           (cl-notany (lambda (tag) (memq tag tags)) must-not-have)
           (or (null matches)
               (cl-every
                (lambda (m)
                  (or (and title      (string-match-p m title))
                      (and link       (string-match-p m link))))
                matches))
           (cl-notany (lambda (m)
                        (or (and title      (string-match-p m title))
                            (and link       (string-match-p m link))))
                      not-matches)
           (or (null feeds)
               (cl-some (lambda (f)
                          (or (string-match-p f feed-id)
                              (string-match-p f feed-title)))
                        feeds))
           (cl-notany (lambda (f)
                        (or (string-match-p f feed-id)
                            (string-match-p f feed-title)))
                      not-feeds)))))

(defun elfeed-search-compile-filter (filter)
  "Compile FILTER into a lambda function for `byte-compile'.

Executing a filter in bytecode form is generally faster than
\"interpreting\" the filter with the function `elfeed-search-filter'."
  (cl-destructuring-bind (&key after     before
                               must-have must-not-have
                               matches   not-matches
                               feeds     not-feeds
                               limit &allow-other-keys)
      filter
    `(lambda (entry feed count &optional now)
       (ignore entry feed count now)
       (let* (,@(when after
                  '((date (elfeed-entry-date entry))
                    (age (- (or now (float-time)) date))))
              ,@(when (or must-have must-not-have)
                  '((tags (elfeed-entry-tags entry))))
              ,@(when (or matches not-matches)
                  '((title (elfeed-meta--title entry))
                    (link (elfeed-entry-link entry))))
              ,@(when (or feeds not-feeds)
                  '((feed-id (elfeed-feed-id feed))
                    (feed-title (or (elfeed-meta--title feed) "")))))
         ,@(when after
             `((when (> age ,after)
                 (elfeed-db-return))))
         ,@(when limit
             `((when (>= count ,limit)
                 (elfeed-db-return))))
         (and ,@(cl-loop for forbid in must-not-have
                         collect `(not (memq ',forbid tags)))
              ,@(cl-loop for forbid in must-have
                         collect `(memq ',forbid tags))
              ,@(cl-loop for regex in matches collect
                         `(or (string-match-p ,regex title)
                              (string-match-p ,regex link)))
              ,@(cl-loop for regex in not-matches collect
                         `(not
                           (or (string-match-p ,regex title)
                               (string-match-p ,regex link))))
              ,@(when feeds
                  `((or ,@(cl-loop
                           for regex in feeds
                           collect `(string-match-p ,regex feed-id)
                           collect `(string-match-p ,regex feed-title)))))
              ,@(when not-feeds
                  `((not
                     (or ,@(cl-loop
                            for regex in not-feeds
                            collect `(string-match-p ,regex feed-id)
                            collect `(string-match-p ,regex feed-title))))))
              ,@(when before
                  `((> age ,before))))))))

(defun elfeed-search--completion-table ()
  "Completion table for the search filter."
  (let (cache)
    (lambda (_str)
      (let ((input
             (if-let* ((win (active-minibuffer-window)))
                 (with-current-buffer (window-buffer win)
                   ;; Obtain input from the minibuffer to compute dynamic
                   ;; candidates.  We cannot use _str since the argument is
                   ;; always empty for non-basic completion styles like
                   ;; substring or orderless.
                   (save-excursion
                     (goto-char (point-max))
                     (when (re-search-backward
                            "\\s-" (minibuffer-prompt-end) 'noerror)
                       (goto-char (min (1+ (point)) (point-max))))
                     (buffer-substring-no-properties (point) (point-max))))
               "")))
        (append
         ;; Dynamically computed @age candidates.
         (when (string-match "\\`@[0-9]+" input)
           (let* ((n (substring input 1 (match-end 0)))
                  (p (if (equal n "1") "" "s")))
             (mapcar (lambda (x) (concat "@" n x p))
                     '("hour" "day" "week" "month" "year"))))
         ;; Static candidates (tags and history).
         (unless (equal input "")
           (let ((all
                  (with-memoization cache
                    (delete-dups
                     (nconc
                      ;; +tag and -tag candidate strings
                      (mapcan
                       (lambda (x) (list (format "+%s" x) (format "-%s" x)))
                       (elfeed-db-get-all-tags))
                      ;; Old words from history
                      (mapcan
                       (lambda (h)
                         (delq nil
                               (mapcar (lambda (x) (and (length> x 1) x))
                                       (split-string h))))
                       elfeed-search-filter-history))))))
             (if (string-match-p "\\`[=~!#@]" input)
                 ;; Keep only candidates matching the special prefix character.
                 (let (completion-regexp-list completion-ignore-case)
                   (all-completions (substring input 0 1) all))
               all))))))))

(defun elfeed-search--prompt (current &optional live)
  "Prompt for a new filter starting from CURRENT, optionally with LIVE update."
  (unless (or (equal "" current) (string-suffix-p " " current))
    (setq current (concat current " ")))
  (let ((elfeed-search--filter-active (if live :live :non-interactive)))
    (minibuffer-with-setup-hook
        (lambda ()
          (set-syntax-table elfeed-search-filter-syntax-table)
          (when live
            (add-hook 'post-command-hook #'elfeed-search--live nil 'local)))
      (if elfeed-search-completion
          (dlet ((crm-separator "[ \t]+")
                 (crm-prompt "%p")
                 (completion-show-inline-help nil))
            (string-join
             (completing-read-multiple
              "Filter: "
              (completion-table-with-metadata
               (completion-table-dynamic
                (elfeed-search--completion-table))
               '((category . elfeed-search)))
              nil nil current 'elfeed-search-filter-history)
             " "))
        (read-from-minibuffer
         "Filter: " current nil nil 'elfeed-search-filter-history)))))

(defun elfeed-search--prompt-tags (prompt &optional entry-or-entries-list)
  "Prompt for tags in the minibuffer.
PROMPT is the prompt string and ENTRY-OR-ENTRIES-LIST an entry or a list
of entries to take the tags from."
  (let* ((all-tags
          (or (if entry-or-entries-list
                  (delete-dups
                   (copy-sequence
                    (apply #'append
                           (mapcar #'elfeed-entry-tags
                                   (ensure-list entry-or-entries-list)))))
                (elfeed-db-get-all-tags))
              (user-error "No tags found")))
         (all-tags (mapcar #'symbol-name all-tags))
         (initial (when entry-or-entries-list
                    (if (length= all-tags 1)
                        (car all-tags)
                      (let ((tag (get-text-property (point) 'elfeed-tag)))
                        (and tag (symbolp tag) (symbol-name tag))))))
         (require-match (not (not entry-or-entries-list)))
         (tags (if elfeed-search-completion
                   (completing-read-multiple
                    prompt
                    (completion-table-with-metadata
                     all-tags
                     '((category . elfeed-tag)))
                    nil require-match initial)
                 (split-string (read-from-minibuffer prompt initial)
                               "[ \t]*,[ \t]*" t))))
    (unless tags
      (user-error "No tags given!"))
    (mapcar #'intern tags)))

(defun elfeed-search-clear-filter ()
  "Reset the search filter.
Use the default value of the variable `elfeed-search-filter'."
  (interactive nil elfeed-search-mode)
  (elfeed-search-set-filter))

(defun elfeed-search-set-filter (&optional new-filter)
  "Set a new search filter for the `elfeed-search' buffer.

When NEW-FILTER is nil, reset the filter to the default value.

When given a prefix argument, the current filter is not displayed
in the minibuffer when prompting for a new filter.

Any component beginning with a + or - is treated as a tag.  If +
the tag must be present on the entry.  If - the tag must *not* be
present on the entry.  Examples: \"+unread\" or \"+unread -comic\".

Any component beginning with an @ is an age limit or an age
range.  If a limit, no posts older than this are allowed.  If a
range, posts dates have to be in-between the specified date
range.  Examples:
- \"@3days\"
- \"@1year\"
- \"@3-days-ago\"
- \"@1-year-old\"
- \"@2019-06-24\"
- \"@2019-06-24--2019-06-24\"
- \"@5-days-ago--1-day-ago\"

Any component beginning with a # is an entry count maximum.  The
number following # determines the maximum number of entries
to be shown (descending by date).  Examples: \"#20\" or \"#100\".

Any component beginning with a = is a regular expression matching
the entry's feed (title or URL). Only entries belonging to a feed
that match at least one of the = expressions will be shown.

Every other space-separated element is treated like a regular
expression, matching against entry link, title, and feed title."
  (interactive
   (list (elfeed-search--prompt
          (if current-prefix-arg "" elfeed-search-filter)))
   elfeed-search-mode)
  (with-current-buffer (elfeed-search-buffer)
    (setf elfeed-search-max-entries (default-value 'elfeed-search-max-entries)
          elfeed-search-filter
          (or new-filter (default-value 'elfeed-search-filter)))
    (elfeed-search-update :force)))

(defun elfeed-search-entries (filter &optional sort ascending)
  "Search database with FILTER string and return a list of matching entries.
The entries are sorted by the entry comparison function SORT.  The result
is reversed if ASCENDING is non-nil."
  (let* ((filter (elfeed-search-parse-filter filter))
         (head (list nil))
         (tail head)
         (count 0)
         (now (float-time)))
    (if elfeed-search-compile-filter
        ;; Force lexical bindings regardless of the current
        ;; buffer-local value. Lexical scope uses the faster
        ;; stack-ref opcode instead of the traditional varref opcode.
        (let ((lexical-binding t)
              (func (byte-compile (elfeed-search-compile-filter filter))))
          (elfeed-db-visit (entry feed)
            (when (funcall func entry feed count now)
              (setf (cdr tail) (list entry)
                    tail (cdr tail)
                    count (1+ count)))))
      (elfeed-db-visit (entry feed)
        (when (elfeed-search-filter filter entry feed count now)
          (setf (cdr tail) (list entry)
                tail (cdr tail)
                count (1+ count)))))
    ;; Determine the final list order
    (let ((entries (cdr head)))
      (when sort (setq entries (sort entries sort)))
      (when ascending (setq entries (nreverse entries)))
      entries)))

(defun elfeed-search--update-list (method)
  "Update the variable `elfeed-search-entries' given the update METHOD.
Returns non-nil if the list has been updated."
  (let (list update)
    (if (eq method :live)
        (while-no-input
          (setq list (elfeed-search-entries
                      elfeed-search-filter
                      (elfeed-search--sort-function)
                      (eq elfeed-search-sort-order 'ascending))
                update t))
      (setq list (elfeed-search-entries
                  elfeed-search-filter
                  (elfeed-search--sort-function)
                  (eq elfeed-search-sort-order 'ascending))
            update t))
    (when update
      (cl-callf2 cl-delete-if-not (lambda (x) (memq x list))
                 elfeed-search--marked)
      (setq elfeed-search-entries list
            elfeed-search--unread-count nil
            elfeed-search--last-update (float-time)
            list-buffers-directory elfeed-search-filter)
      t)))

(defun elfeed-search-update (&optional method)
  "Update the `elfeed-search' buffer listing to match the database.
When METHOD is non-nil, redraw even when the database hasn't changed.
Otherwise debounce by `elfeed-search-update-delay' and only redraw when
there are changes.  When called interactively METHOD is :force, and the
command behaves just like `revert-buffer'."
  (declare (completion ignore)) ;; Press "g" or M-x revert-buffer
  (interactive (list :force))
  (when elfeed-search--update-timer
    (cancel-timer elfeed-search--update-timer)
    (setq elfeed-search--update-timer nil))
  (when-let* ((buffer (get-buffer "*elfeed-search*")))
    (if method
        (elfeed-search--update-immediately
         buffer (if (keywordp method) method :force))
      (setf elfeed-search--update-timer
            (run-at-time elfeed-search-update-delay nil
                         #'elfeed-search--update-immediately
                         buffer)))))

(defun elfeed-search--print-entry (entry)
  "Print ENTRY line and attach `elfeed-entry' text property."
  (let ((beg (point)))
    (funcall elfeed-search-print-entry-function entry)
    (put-text-property beg (point) 'elfeed-entry entry)))

(defun elfeed-search--update-immediately (buffer &optional method)
  "Immediately update the `elfeed-search' BUFFER.
METHOD can be nil, :force to force a full entry update and redraw or
:resize to preserve the entries and redraw.  Do not use this function
directly.  Instead use `elfeed-search-update'."
  (when (and (buffer-live-p buffer)
             (or (memq method '(:force :resize :extend :live))
                 (and (not elfeed-search--filter-active)
                      (< elfeed-search--last-update (elfeed-db-last-update)))))
    ;; Run inside window such that save excursion moves the window point.
    (with-selected-window (or (get-buffer-window buffer) (selected-window))
      ;; If no window is found, we still have to execute in the buffer.
      (with-current-buffer buffer
        (when (or (memq method '(:resize :extend))
                  (elfeed-search--update-list method))
          (elfeed-with-position elfeed-entry
            (let* ((inhibit-read-only t)
                   (standard-output (current-buffer))
                   (max (or elfeed-search-max-entries most-positive-fixnum))
                   (limit max)
                   (list elfeed-search-entries)
                   (count (length list)))
              (goto-char (max (point-min) (1- (point-max))))
              (if (eq method :extend)
                  (let ((n (get-text-property (point) 'elfeed-search--truncated)))
                    (if (not n)
                        (setq limit 0 list nil)
                      (delete-region (pos-bol) (point-max))
                      (cl-callf2 drop n list)
                      (decf limit n)))
                (erase-buffer)
                (remove-overlays nil nil 'category 'elfeed-search-marked))
              (cl-loop for entry in list repeat limit do
                       (elfeed-search--print-entry entry)
                       (insert ?\n))
              (unless (eq method :extend)
                (mapc #'elfeed-search--make-marked-overlay elfeed-search--marked))
              (when (> count max)
                (insert (elfeed-search--truncated count max)))))
          ;; Highlighting gets lost due to debouncing.
          (hl-line-highlight)
          (run-hooks 'elfeed-search-update-hook)))))
  ;; Always force a header line update
  (when (buffer-live-p buffer)
    (with-current-buffer buffer
      (force-mode-line-update))))

(defun elfeed-search--truncated (count max)
  "Truncation button given entry COUNT and MAX entries to display."
  (elfeed-add-properties
   (format
    "[Extend truncated list, %s/%s, %s%%]\n"
    max count (/ (* 100 max) count))
   'face 'button
   'mouse-face 'highlight
   'keymap (define-keymap
             "RET" #'elfeed-search-extend
             "<mouse-1>" #'elfeed-search-extend)
   'elfeed-search--truncated max))

(defun elfeed-search--update-force (&rest _)
  "Call `elfeed-search-update' with argument :force.
The function is used as hook.  Instead of this function, you usually
want to use `elfeed-search-update'."
  (elfeed-search-update :force))

(defun elfeed-search--update-debounce (&rest _)
  "Call `elfeed-search-update' with debouncing.
The function is used as hook.  Instead of this function, you usually
want to use `elfeed-search-update'."
  (elfeed-search-update))

(defun elfeed-search--resize (win)
  "Resize search window WIN.
The function is used as hook."
  (when (/= (window-width win) elfeed-search--last-width)
    (when elfeed-search--resize-timer
      (cancel-timer elfeed-search--resize-timer)
      (setq elfeed-search--resize-timer nil))
    (when elfeed-search-resize-delay
      (setf elfeed-search--last-width (window-width win)
            elfeed-search--resize-timer
            (run-at-time elfeed-search-resize-delay nil
                         #'elfeed-search--update-immediately
                         (elfeed-search-buffer) :resize)))))

(defun elfeed-search-fetch (prefix)
  "Update all feeds via `elfeed-update', or only visible feeds with PREFIX.
Given a prefix, this function becomes `elfeed-search-fetch-visible'."
  (interactive "P" elfeed-search-mode)
  (if prefix
      (elfeed-search-fetch-visible)
    (elfeed-update)))

(defun elfeed-search-fetch-visible ()
  "Update any feed with an entry currently displayed in the search buffer."
  (interactive nil elfeed-search-mode)
  (when (> (elfeed-queue-count-total) 0)
    (user-error "Update already running"))
  (setq elfeed-log-error-count 0)
  (elfeed-log 'info "Update visible feeds: %s"
              (format-time-string "%B %e %Y %H:%M:%S %Z"))
  (run-hooks 'elfeed-update-init-hook)
  (cl-loop with feeds = (make-hash-table :test #'eq)
           for entry in elfeed-search-entries
           for feed = (elfeed-entry-feed entry)
           unless (gethash feed feeds) do
           (setf (gethash feed feeds) t)
           (elfeed--update-feed (elfeed-feed-url feed))))

(defun elfeed-search--update-line (&optional n)
  "Redraw line N, defaulting to the current line."
  (let ((inhibit-read-only t))
    (save-excursion
      (when n (elfeed-goto-line n))
      (when-let* ((entry (elfeed-search-selected :ignore-region)))
        (elfeed-search--remove-marked-overlay entry)
        (delete-region (pos-bol) (pos-eol))
        (elfeed-search--print-entry entry)
        (when (memq entry elfeed-search--marked)
          (elfeed-search--make-marked-overlay entry))))))

(defun elfeed-search-update-entry (&rest entries)
  "Redraw ENTRIES in the `elfeed-search' buffer."
  (setq elfeed-search--unread-count nil)
  (if (length> entries 100)
      (elfeed-search-update :force)
    (cl-loop for entry in entries
             for n = (cl-position entry elfeed-search-entries)
             when n do (elfeed-search--update-line (1+ n)))))

(defun elfeed-search--remove-marked-overlay (entry)
  "Remove mark overlay from ENTRY."
  (when-let* ((n (cl-position entry elfeed-search-entries)))
    (save-excursion
      (elfeed-goto-line (1+ n))
      (remove-overlays (pos-bol) (pos-eol)
                       'category 'elfeed-search-marked))))

(defun elfeed-search--make-marked-overlay (entry)
  "Add mark overlay over ENTRY."
  (when-let* ((n (cl-position entry elfeed-search-entries)))
    (save-excursion
      (elfeed-goto-line (1+ n))
      (overlay-put (make-overlay (pos-bol) (pos-eol))
                   'category 'elfeed-search-marked))))

(defun elfeed-search-mark (&rest entries)
  "Mark ENTRIES at point."
  (interactive (elfeed-search-selected :ignore-marked)
               elfeed-search-mode)
  (dolist (entry entries)
    (cl-pushnew entry elfeed-search--marked)
    (elfeed-search--make-marked-overlay entry))
  (elfeed-search--after-action 'mark))

(defun elfeed-search-unmark (&rest entries)
  "Unmark ENTRIES at point.
With prefix argument, unmark all currently marked entries."
  (interactive (if current-prefix-arg
                   elfeed-search--marked
                 (elfeed-search-selected :ignore-marked))
               elfeed-search-mode)
  (dolist (entry entries)
    (cl-callf2 delq entry elfeed-search--marked)
    (elfeed-search--remove-marked-overlay entry))
  (unless current-prefix-arg
    (elfeed-search--after-action 'mark)))

(defun elfeed-search-selected (&optional ignore-region)
  "Return a list of the currently selected feeds.

If IGNORE-REGION is :ignore-marked, do not return marked entries.
Otherwise if IGNORE-REGION is non-nil, only return the entry under
point."
  (let* ((ignore-marked (eq ignore-region :ignore-marked))
         (ignore-region (and (not ignore-marked) ignore-region)))
    (cond
     ((and (not ignore-region) (not ignore-marked) elfeed-search--marked))
     ((and (not ignore-region) (use-region-p))
      (let ((start (region-beginning))
            (end   (region-end)))
        (save-excursion
          (goto-char start)
          (goto-char (pos-bol))
          (cl-loop while (< (point) end)
                   when (prog1 (get-text-property (point) 'elfeed-entry)
                          (forward-line))
                   collect it into selected
                   finally return selected))))
     ((when-let* ((entry (get-text-property (pos-bol) 'elfeed-entry)))
        (if ignore-region entry (list entry)))))))

(defun elfeed-search-browse-url (&optional secondary)
  "Visit the current entry in your browser using `browse-url'.
If there is a prefix argument SECONDARY, visit the current entry in
the browser defined by `browse-url-secondary-browser-function'."
  (interactive "P" elfeed-search-mode)
  (let ((entries (elfeed-search-selected)))
    (when (elfeed--confirm-browse-url-p (length entries))
      (cl-loop for entry in entries
               when (elfeed-entry-link entry)
               do (progn
                    (elfeed-untag entry 'unread)
                    (elfeed-browse-url it secondary)))
      ;; `browse-url' could have switched to another buffer if eww or another
      ;; internal browser is used, but the remainder of the functions needs to
      ;; run in the elfeed buffer.
      (with-current-buffer (elfeed-search-buffer)
        (apply #'elfeed-search-update-entry entries)
        (elfeed-search--after-action 'browse)))))

(defun elfeed-search-browse-url-secondary ()
  "Visit the current entry in your browser using the secondary browser."
  (interactive nil elfeed-search-mode)
  (elfeed-search-browse-url t))

(defun elfeed-search-yank ()
  "Copy the selected feed items to clipboard and `kill-ring'."
  (interactive nil elfeed-search-mode)
  (let* ((entries (elfeed-search-selected))
         (links (mapcar #'elfeed-entry-link entries))
         (links-str (string-join links " ")))
    (when entries
      (kill-new links-str)
      (gui-set-selection elfeed-search-clipboard-type links-str)
      (message "Yanked: %s" links-str)
      (elfeed-search--after-action 'yank))))

(defun elfeed-search--tag (query tags fun)
  "Call FUN with the entries and TAGS and update the entries afterwards.
If `elfeed-search-confirm-tag' is non-nil, confirm with QUERY."
  (let* ((all current-prefix-arg)
         (entries (if all elfeed-search-entries (elfeed-search-selected)))
         (confirm elfeed-search-confirm-tag)
         (count (length entries)))
    (when (or (not confirm)
              (and (numberp confirm) (< count confirm))
              (y-or-n-p (format query count (mapconcat #'symbol-name tags ", "))))
      (apply #'elfeed-search-update-entry (apply fun entries tags))
      (unless all
        (elfeed-search--after-action 'tag)))))

(defun elfeed-search-tag (&rest tags)
  "Apply TAGS to all selected entries.
With \\[universal-argument] pressed before, modify all entries of the list."
  (interactive (elfeed-search--prompt-tags "Tag: ") elfeed-search-mode)
  (elfeed-search--tag "Tag %s entries with %s?" tags #'elfeed-tag))

(defun elfeed-search-untag (&rest tags)
  "Remove TAGS from all selected entries.
With \\[universal-argument] pressed before, modify all entries of the list."
  (interactive (elfeed-search--prompt-tags
                "Untag: "
                (if current-prefix-arg
                    elfeed-search-entries
                  (elfeed-search-selected)))
   elfeed-search-mode)
  (elfeed-search--tag "Untag %s entries with %s?" tags #'elfeed-untag))

(defun elfeed-search-toggle (&rest tags)
  "Toggle TAGS on all selected entries.
With \\[universal-argument] pressed before, modify all entries of the list."
  (interactive (elfeed-search--prompt-tags "Toggle: ") elfeed-search-mode)
  (elfeed-search--tag "Toggle %2$s of %1$s entries?" tags
   (lambda (entries &rest tags)
     (dolist (tag tags entries)
       (let (entries-tag entries-untag)
         (cl-loop for entry in entries
                  when (elfeed-tagged-p tag entry)
                  do (push entry entries-untag)
                  else do (push entry entries-tag))
         (elfeed-tag entries-tag tag)
         (elfeed-untag entries-untag tag))))))

(defun elfeed-search-show-entry (entry &optional preview)
  "Display the currently selected ENTRY in a buffer.
If the prefix argument PREVIEW is non-nil, do not mark the entry as
read.  If the feed specifies an alternative `:show-entry' function,
e.g., `browse-url', this function will be called with the entry link as
argument."
  (interactive (list (or (elfeed-search-selected :ignore-region)
                         (user-error "No entry selected!"))
                     current-prefix-arg)
               elfeed-search-mode)
  (when (and (not preview) (elfeed-untag entry 'unread))
    (elfeed-search-update-entry entry))
  (elfeed-search--after-action 'show)
  ;; Update hl-line overlay. This does not happen automatically, since
  ;; `elfeed-show-entry' switches to another buffer.
  (hl-line-highlight)
  (push-mark nil 'nomsg)
  (if-let* ((feed (elfeed-entry-feed entry))
            (show (elfeed-meta feed :show-entry))
            (link (elfeed-entry-link entry)))
      (funcall show link)
    ;; elfeed-show.el is required by elfeed.el at runtime.
    (declare-function elfeed-show-entry "elfeed-show")
    (elfeed-show-entry entry)))

(defun elfeed-search--toggle-filter (str)
  "Toggle STR filter in variable `elfeed-search-filter'."
  (let ((filter (split-string elfeed-search-filter)))
    (elfeed-search-set-filter
     (string-join (if (member str filter)
                      (delete str filter)
                    (append filter (list str)))
                  " "))))

(defun elfeed-search--date-filter (date)
  "Create filter string which matches a DATE."
  (concat (format-time-string "@%Y-%m-%d" date)
          (format-time-string "--%Y-%m-%d" (+ (* 24 60 60) date))))

(defun elfeed-search--tag-filter (tag-or-tags-list)
  "Create filter string which matches a TAG-OR-TAGS-LIST."
  (mapconcat (lambda (x) (format "+%s" x))
             (ensure-list tag-or-tags-list) " "))

(defun elfeed-search--feed-filter (feed &optional exclude)
  "Create filter string which matches FEED.
If EXCLUDE is non-nil, create filter string excluding FEED instead."
  (concat (if exclude "~" "=")
          (string-replace "\\." "." (regexp-quote (elfeed-feed-id feed)))))

(defun elfeed-search-date-filter ()
  "Toggle date filter from date at point."
  (interactive nil elfeed-search-mode)
  (when-let* ((entry (get-text-property (pos-bol) 'elfeed-entry)))
    (elfeed-search--toggle-filter (elfeed-search--date-filter
                                   (elfeed-entry-date entry)))))

(defun elfeed-search-tag-filter ()
  "Toggle tag filter from tag at point."
  (interactive nil elfeed-search-mode)
  (when-let* ((tag (get-text-property (point) 'elfeed-tag)))
    (elfeed-search--toggle-filter (elfeed-search--tag-filter tag))))

(defun elfeed-search-feed-filter (&optional exclude)
  "Toggle feed filter from feed at point.
Create an exclude filter if prefix argument EXCLUDE is non-nil."
  (interactive "P" elfeed-search-mode)
  (when-let* ((entry (get-text-property (pos-bol) 'elfeed-entry)))
    (elfeed-search--toggle-filter
     (elfeed-search--feed-filter (elfeed-entry-feed entry) exclude))))

(defun elfeed-search-exclude-feed-filter ()
  "Exclude feed at point from search results."
  (interactive nil elfeed-search-mode)
  (elfeed-search-feed-filter t))

(defun elfeed-search-set-entry-title (&optional title)
  "Manually set TITLE for the entry under point.
Sets the :title key of the entry's metadata.  See `elfeed-meta'."
  (interactive nil elfeed-search-mode)
  (let ((entry (or (elfeed-search-selected :ignore-region)
                   (user-error "No entry selected!"))))
    (unless title
      (setq title (read-from-minibuffer "Entry title: "
                                        (elfeed-meta--title entry))))
    (setf (elfeed-meta entry :title) title)
    (elfeed-search-update-entry entry)))

(defun elfeed-search-set-feed-title (&optional title)
  "Manually set TITLE for the feed belonging to the entry under point.
Sets the :title key of the feed's metadata.  See `elfeed-meta'."
  (interactive nil elfeed-search-mode)
  (let ((feed (elfeed-entry-feed
               (or (elfeed-search-selected :ignore-region)
                   (user-error "No entry selected!")))))
    (unless title
      (setq title (read-from-minibuffer "Feed title: "
                                        (elfeed-meta--title feed))))
    (setf (elfeed-meta feed :title) title)
    (elfeed-search-update :force)))

;; Live Filters

(defvar elfeed-search-filter-syntax-table
  (let ((table (make-syntax-table)))
    (modify-syntax-entry ?+ "w" table)
    (modify-syntax-entry ?- "w" table)
    (modify-syntax-entry ?~ "w" table)
    (modify-syntax-entry ?= "w" table)
    (modify-syntax-entry ?@ "w" table)
    (modify-syntax-entry ?# "w" table)
    (modify-syntax-entry ?: "_" table)
    (modify-syntax-entry ?/ "_" table)
    (modify-syntax-entry ?. "_" table)
    table)
  "Syntax table active when editing the filter in the minibuffer.")

(defun elfeed-search--live-immediately (buffer)
  "Update the `elfeed-search' buffer based on the contents of the minibuffer.
BUFFER is the active minibuffer."
  (when-let* (((buffer-live-p buffer))
              (filter (with-current-buffer buffer
                        (minibuffer-contents-no-properties))))
    (with-current-buffer (elfeed-search-buffer)
      (let* ((window (get-buffer-window))
             (height (window-total-height window))
             (limiter (if window (format "#%d " height) "#1 "))
             (elfeed-search-filter (concat limiter filter)))
        (elfeed-search-update :live)
        (when (length= elfeed-search-entries height)
          (setq elfeed-search--unread-count
                (propertize "?/?:?" 'face 'elfeed-search-unread-count-face)))))))

(defun elfeed-search--live ()
  "Update the `elfeed-search' buffer live based on the contents of the minibuffer.
The update is delayed by `elfeed-search-live-delay'."
  (when elfeed-search--live-timer
    (cancel-timer elfeed-search--live-timer)
    (setq elfeed-search--live-timer nil))
  (setf elfeed-search--live-timer
        (run-at-time elfeed-search-live-delay nil
                     #'elfeed-search--live-immediately
                     (current-buffer))))

(defun elfeed-search-live-filter ()
  "Filter the `elfeed-search' buffer as the filter is written."
  (interactive nil elfeed-search-mode)
  (unwind-protect
      (setq elfeed-search-max-entries (default-value 'elfeed-search-max-entries)
            elfeed-search-filter (elfeed-search--prompt elfeed-search-filter :live))
    (elfeed-search-update :force)))

(defun elfeed-search-cycle-order ()
  "Cycle between different sort functions."
  (interactive nil elfeed-search-mode)
  (setq-local elfeed-search-sort-function
              (pcase elfeed-search-sort-function
                (`nil (list #'elfeed-search-group-by-feed nil))
                ((pred functionp) (list nil elfeed-search-sort-function))
                (_ (cons (car (last elfeed-search-sort-function))
                         (butlast elfeed-search-sort-function)))))
  (elfeed-search-update :force))

(defun elfeed-search-reverse-order ()
  "Reverse sort order."
  (interactive nil elfeed-search-mode)
  (setq-local elfeed-search-sort-order
              (if (eq elfeed-search-sort-order 'ascending)
                  'descending
                'ascending))
  (elfeed-search-update :force))

(defun elfeed-search-extend (&optional all)
  "Extend truncated list.
With prefix argument ALL show all entries."
  (interactive "P" elfeed-search-mode)
  (setq-local elfeed-search-max-entries
              (and (not all)
                   elfeed-search-max-entries
                   (+ elfeed-search-max-entries
                      (default-value 'elfeed-search-max-entries))))
  (elfeed-search-update :extend))

;; Separators in search display

;; Default separator title function
(put nil 'elfeed-search-separator
     (lambda (entry)
       (when elfeed-search-separator-date-format
         (format-time-string elfeed-search-separator-date-format
                             (seconds-to-time (elfeed-entry-date entry))))))

;; Separator title function for `elfeed-search-group-by-feed'
(put #'elfeed-search-group-by-feed 'elfeed-search-separator
     (lambda (entry)
       (elfeed-meta--title (elfeed-entry-feed entry))))

(defun elfeed-search--separator-title (entry)
  "Format separator title for ENTRY."
  (let ((sort (elfeed-search--sort-function)))
    (funcall (or (and (symbolp sort) (get sort 'elfeed-search-separator))
                 (get nil 'elfeed-search-separator))
             entry)))

(defun elfeed-search-add-separators ()
  "Add separators to the search buffer."
  (let ((last nil) (ov nil) (count 0))
    (remove-overlays (point-min) (point-max)
                     'category 'elfeed-search-separator)
    (save-excursion
      (goto-char (point-min))
      (while (not (eobp))
        (when-let* ((entry (get-text-property (point) 'elfeed-entry))
                    (title (elfeed-search--separator-title entry))
                    ((not (equal title last))))
          (incf count)
          (setq ov (make-overlay (pos-bol) (pos-bol)))
          (overlay-put ov 'category 'elfeed-search-separator)
          (overlay-put ov 'before-string
                       (concat (and last "\n")
                               (elfeed-add-properties
                                (concat title "\n")
                                'face 'elfeed-search-separator-face)))
          (setq last title))
        (forward-line)))
    ;; Delete unnecessary separator again if there is only a single one.
    (when (= count 1) (delete-overlay ov))))

(defun elfeed-search-next-separator (&optional n)
  "Move to the next Nth separator line.
Positive N moves forward, negative N backward."
  (interactive "p" elfeed-search-mode)
  (setq n (or n 1))
  (goto-char (pos-bol))
  (cl-loop repeat (abs n) do
           (cl-loop until
                    (or (/= 0 (forward-line (if (> n 0) 1 -1)))
                        (cl-loop for ov in (overlays-in (pos-bol) (pos-bol))
                                 thereis
                                 (eq (overlay-get ov 'category)
                                     'elfeed-search-separator)))))
  (if (eobp)
      (forward-line -1)
    (set-window-start nil (pos-bol))))

(defun elfeed-search-previous-separator (&optional n)
  "Move cursor to the previous Nth separator.
Positive N moves backward, negative N moves forward."
  (interactive "p" elfeed-search-mode)
  (elfeed-search-next-separator (- (or n 1))))

;; Bookmarks

;;;###autoload
(defun elfeed-search-bookmark-handler (record)
  "Jump to an `elfeed-search' bookmark RECORD."
  (elfeed-search (bookmark-prop-get record 'location)))
(put 'elfeed-search-bookmark-handler 'bookmark-handler-type "Elfeed")

(defun elfeed-search-bookmark-make-record ()
  "Return a bookmark record for the current `elfeed-search' buffer."
  `(,(format "elfeed %s" elfeed-search-filter)
    (location . ,elfeed-search-filter)
    (tags ,@(plist-get (elfeed-search-parse-filter elfeed-search-filter) :must-have))
    (handler . ,#'elfeed-search-bookmark-handler)))

;; Desktop Save

(defun elfeed-search-desktop-save (_desktop-dirname)
  "Save the state of the current `elfeed-search' buffer.
The state may be restored as part of a saved desktop.  Also save the
state of the db for when `desktop-auto-save-timeout' is enabled."
  (elfeed-db-save)
  elfeed-search-filter)

;;;###autoload
(defun elfeed-search-desktop-restore (_file-name _buffer-name search-filter)
  "Restore the SEARCH-FILTER of an `elfeed-search' buffer on desktop restore."
  (elfeed-search search-filter)
  (current-buffer))

;;;###autoload
(add-to-list 'desktop-buffer-mode-handlers
             '(elfeed-search-mode . elfeed-search-desktop-restore))

;; Keep old names to avoid breakage.
(define-obsolete-function-alias 'elfeed-search-quit-window
  #'quit-window "4.0.0")
(define-obsolete-function-alias 'elfeed-search-update--force
  #'revert-buffer "4.0.0")
(define-obsolete-function-alias 'elfeed-search-update-line
  #'elfeed-search--update-line "4.0.0")
(define-obsolete-function-alias 'elfeed-search-tag-all
  #'elfeed-search-tag "4.1.1")
(define-obsolete-function-alias 'elfeed-search-untag-all
  #'elfeed-search-untag "4.1.1")
(define-obsolete-function-alias 'elfeed-search-toggle-all
  #'elfeed-search-toggle "4.1.1")
(define-obsolete-function-alias 'elfeed-search-tag-all-unread
  #'elfeed-search-tag-unread "4.1.1")
(define-obsolete-function-alias 'elfeed-search-untag-all-unread
  #'elfeed-search-untag-unread "4.1.1")

(provide 'elfeed-search)
;;; elfeed-search.el ends here
