summaryrefslogtreecommitdiff
path: root/emacs/site-lisp/consult-recoll.el
diff options
context:
space:
mode:
Diffstat (limited to 'emacs/site-lisp/consult-recoll.el')
-rw-r--r--emacs/site-lisp/consult-recoll.el333
1 files changed, 333 insertions, 0 deletions
diff --git a/emacs/site-lisp/consult-recoll.el b/emacs/site-lisp/consult-recoll.el
new file mode 100644
index 0000000..5410b5e
--- /dev/null
+++ b/emacs/site-lisp/consult-recoll.el
@@ -0,0 +1,333 @@
+;;; consult-recoll.el --- Recoll queries using consult -*- lexical-binding: t; -*-
+
+;; Author: Jose A Ortega Ruiz <jao@gnu.org>
+;; Maintainer: Jose A Ortega Ruiz <jao@gnu.org>
+;; Keywords: docs, convenience
+;; License: GPL-3.0-or-later
+;; Version: 1.0.0
+;; Package-Requires: ((emacs "26.1") (consult "2.0"))
+;; Homepage: https://codeberg.org/jao/consult-recoll
+
+;; Copyright (C) 2021-2025 Free Software Foundation, Inc.
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; A `consult-recoll' command to perform interactive queries (including life
+;; previews of documment snippets) over your Recoll
+;; (https://www.lesbonscomptes.com/recoll/) index, using consult.
+;;
+;; Use
+;;
+;; M-x consult-recoll
+;;
+;; to get started, and check the corresponding customization group for ways to
+;; tweak its behaviour to your needs.
+
+;;; Code:
+
+(require 'seq)
+(require 'subr-x)
+(require 'files)
+
+(eval-when-compile (require 'cl-lib))
+
+(require 'consult)
+
+(declare-function eww-open-file "eww")
+
+(defgroup consult-recoll nil
+ "Options for consult recoll."
+ :group 'consult)
+
+(defcustom consult-recoll-prompt "Recoll search: "
+ "Prompt used by `consult-recoll'."
+ :type 'string)
+
+(defcustom consult-recoll-program "recollq"
+ "Program (or full path to program) used to perform text searches.
+
+This is typically recollq if you have a standard recoll distribution,
+but can be also be set to recoll, or the full path to it if it's
+not in your PATH. In the latter case, you'll want to add -t to
+`consult-recoll-search-flags'."
+ :type 'string)
+
+(defcustom consult-recoll-search-flags 'query
+ "List of flags used to perform queries via recollq.
+
+The special values query and terms will select for you flags
+adequated to perform searches using recoll's query language or
+simply search for all provided terms, respectively.
+
+If you don't care about snippets and need speedier searches, you
+can also set this variable to the special values no-snippets or
+terms-no-snippets for query/all-terms searches that will return
+no snippets.."
+ :type '(choice (const :tag "Query language" query)
+ (const :tag "All terms" terms)
+ (const :tag "Query language sans snippets" no-snippets)
+ (const :tag "All terms sans snippets" terms-no-snippets)
+ (list string)))
+
+(defcustom consult-recoll-open-fn nil
+ "Default function used to open candidate URLs.
+It receives the full path to the file to open and (if
+`consult-recoll-inline-snippets' is set, a page number for the
+snippet at hand. If set to nil, find-file is used. See also
+`consult-recoll-open-fns'"
+ :type '(choice (const nil) function))
+
+(defcustom consult-recoll-open-fns ()
+ "Alist mapping mime types to functions to open a selected candidate.
+If you are setting `consult-list-snippets' to t, these functions
+will be called with two arguments (a file path and a page
+number), otherwise just with one."
+ :type '(alist :key-type string :value-type function))
+
+(defcustom consult-recoll-inline-snippets nil
+ "Show snippets as completion candidates in the minibuffer."
+ :type 'boolean)
+
+(defcustom consult-recoll-group-by-mime t
+ "When set, list search results grouped by mime type."
+ :type 'boolean)
+
+(defcustom consult-recoll-format-candidate nil
+ "A function taking title, path and mime type, and formatting them for display.
+Set to nil to use the default `title (path)' format."
+ :type '(choice (const nil) function))
+
+(defface consult-recoll-url-face '((t :inherit link))
+ "Face used to display URLs of candidates.")
+
+(defface consult-recoll-title-face '((t :inherit italic))
+ "Face used to display titles of candidates.")
+
+(defface consult-recoll-mime-face '((t :inherit font-lock-comment-face))
+ "Face used to display MIME type of candidates.")
+
+(defvar consult-recoll-history nil "History for `consult-recoll'.")
+(defvar consult-recoll--current nil)
+(defvar consult-recoll--index 0)
+(defvar consult-recoll--snippets nil)
+
+(defun consult-recoll--search-flags ()
+ "Compute search flags according to `consult-recoll-search-flags'."
+ (cond ((listp consult-recoll-search-flags)
+ consult-recoll-search-flags)
+ ((symbolp consult-recoll-search-flags)
+ (cl-case consult-recoll-search-flags
+ (terms '("-A" "-p" "5" "-a"))
+ (no-snippets '())
+ (terms-no-snippets '("-a"))
+ (t '("-A" "-p" "5"))))
+ (t (user-error "Invalid value of `consult-recoll-search-flags'"))))
+
+(defun consult-recoll--command (text)
+ "Command used to perform queries for TEXT."
+ (setq consult-recoll--current nil)
+ (setq consult-recoll--index 0)
+ (setq consult-recoll--snippets nil)
+ `(,consult-recoll-program ,@(consult-recoll--search-flags) ,text))
+
+(defun consult-recoll--format (title urln mime)
+ (if consult-recoll-format-candidate
+ (funcall consult-recoll-format-candidate title urln mime)
+ (format "%s (%s)"
+ (propertize title 'face 'consult-recoll-title-face)
+ (propertize urln 'face 'consult-recoll-url-face))))
+
+(defsubst consult-recoll--candidate-title (candidate)
+ (get-text-property 0 'title candidate))
+
+(defsubst consult-recoll--candidate-mime (candidate)
+ (get-text-property 0 'mime-type candidate))
+
+(defsubst consult-recoll--candidate-url (candidate)
+ (get-text-property 0 'url candidate))
+
+(defsubst consult-recoll--candidate-size (candidate)
+ (get-text-property 0 'size candidate))
+
+(defsubst consult-recoll--candidate-page (candidate)
+ (get-text-property 0 'page candidate))
+
+(defsubst consult-recoll--candidate-index (candidate)
+ (get-text-property 0 'index candidate))
+
+(defsubst consult-recoll--snippets (candidate)
+ (let* ((len (length consult-recoll--snippets))
+ (idx (or (consult-recoll--candidate-index candidate) 0))
+ (pos (- len idx)))
+ (if (>= pos len)
+ ""
+ (mapconcat 'identity (reverse (elt consult-recoll--snippets pos)) "\n"))))
+
+(defun consult-recoll--search-snippet (candidate _mime)
+ "When CANDIDATE is the text of a snippet, search for it in current buffer."
+ (when (string-match "^\s+0 : " candidate)
+ (let ((txt (replace-match "" nil nil candidate)))
+ (goto-char (point-min))
+ (when (or (search-forward txt nil t)
+ (and (derived-mode-p 'org-mode)
+ (let ((txt (replace-regexp-in-string "\\]\\].+" "" txt)))
+ (search-forward txt nil t)))
+ (let ((mid (/ (length txt) 2)))
+ (or (search-forward (substring txt 0 mid) nil t)
+ (search-forward (substring txt mid) nil t))))
+ (goto-char (match-beginning 0))
+ (when (derived-mode-p 'org-mode) (org-reveal))))))
+
+(declare-function doc-view-goto-page "doc-view")
+(declare-function pdf-view-goto-page "ext:pdf-view")
+
+(defun consult-recoll--open-file (filename &optional page)
+ "Default function for opening result files."
+ (find-file filename)
+ (when page
+ (cond ((derived-mode-p 'doc-view-mode) (doc-view-goto-page page))
+ ((derived-mode-p 'pdf-view-mode) (pdf-view-goto-page page)))))
+
+(defun consult-recoll--open (candidate)
+ "Open file of corresponding completion CANDIDATE."
+ (when candidate
+ (let* ((url (consult-recoll--candidate-url candidate))
+ (mime (consult-recoll--candidate-mime candidate))
+ (open (cond ((cdr (assoc mime consult-recoll-open-fns)))
+ (consult-recoll-open-fn)
+ ((string= mime "text/html")
+ (lambda (f &optional _ignored) (eww-open-file f)))
+ (t #'consult-recoll--open-file))))
+ (if (not consult-recoll-inline-snippets)
+ (funcall open url)
+ (funcall open url (consult-recoll--candidate-page candidate))
+ (when (or (string-prefix-p "text/" mime)
+ (string-prefix-p "message/" mime))
+ (consult-recoll--search-snippet candidate mime))))))
+
+(defconst consult-recoll--line-rx
+ "^\\(.*?\\)\t\\[\\(.*?\\)\\]\t\\[\\(.*\\)\\]\\(\t\\([0-9]+\\)\\)?"
+ "Regular expression decomposing result lines returned by recollq")
+
+(defun consult-recoll--transformer (str)
+ "Decode STR, as returned by recollq."
+ (cond ((string-match consult-recoll--line-rx str)
+ (let* ((mime (match-string 1 str))
+ (url (match-string 2 str))
+ (title (match-string 3 str))
+ (size (match-string 5 str))
+ (urln (if (string-prefix-p "file://" url) (substring url 7) url))
+ (idx (setq consult-recoll--index (1+ consult-recoll--index)))
+ (cand (consult-recoll--format title url mime))
+ (cand (propertize cand
+ 'mime-type mime
+ 'url urln
+ 'title title
+ 'index idx
+ 'size size)))
+ (push () consult-recoll--snippets)
+ (setq consult-recoll--current cand)))
+ ((string-match-p "^/?SNIPPETS$" str) nil)
+ ((and consult-recoll-inline-snippets consult-recoll--current)
+ (when-let* ((page (and (string-match "^\\([0-9]+\\) :" str)
+ (match-string 1 str)))
+ (pageno (and page (string-to-number page)))
+ (props (text-properties-at 0 consult-recoll--current)))
+ (apply #'propertize (concat " " str) 'page pageno props)))
+ (consult-recoll--current
+ (push str (car consult-recoll--snippets))
+ nil)))
+
+(defvar consult-recoll--preview-buffer "*consult-recoll preview*")
+
+(defun consult-recoll--preview (action candidate)
+ "Preview search result CANDIDATE when ACTION is \\='preview."
+ (cond ((or (eq action 'setup) (null candidate))
+ (with-current-buffer (get-buffer-create consult-recoll--preview-buffer)
+ (setq-local cursor-in-non-selected-windows nil)
+ (delete-region (point-min) (point-max))))
+ ((and (eq action 'preview) candidate)
+ (when-let* ((url (consult-recoll--candidate-url candidate))
+ (buff (get-buffer consult-recoll--preview-buffer)))
+ (with-current-buffer buff
+ (delete-region (point-min) (point-max))
+ (when-let (title (consult-recoll--candidate-title candidate))
+ (insert (propertize title 'face 'consult-recoll-title-face) "\n"))
+ (insert (propertize url 'face 'consult-recoll-url-face) "\n")
+ (insert (propertize (consult-recoll--candidate-mime candidate)
+ 'face 'consult-recoll-mime-face))
+ (when-let (s (consult-recoll--snippets candidate))
+ (insert "\n\n" s))
+ (goto-char (point-min)))
+ (pop-to-buffer buff)))
+ ((eq action 'exit)
+ (when (get-buffer consult-recoll--preview-buffer)
+ (kill-buffer consult-recoll--preview-buffer)))))
+
+(defun consult-recoll--group (candidate transform)
+ "If TRANSFORM return candidate, othewise extract mime-type."
+ (if transform candidate (consult-recoll--candidate-mime candidate)))
+
+(defun consult-recoll--format-size (bytes)
+ "Format the given size with adaptive units."
+ (file-size-human-readable (string-to-number bytes) nil " " "B"))
+
+(defun consult-recoll--annotation (candidate)
+ "Annotation for the given CANDIDATE (its size by default)"
+ (and (not (consult-recoll--candidate-page candidate))
+ (format " (%s)" (consult-recoll--format-size
+ (consult-recoll--candidate-size candidate)))))
+
+(defun consult-recoll--search (&optional initial)
+ "Perform an asynchronous recoll search via `consult--read'.
+If given, use INITIAL as the starting point of the query."
+ (consult--read (consult--async-pipeline
+ (consult--process-collection #'consult-recoll--command)
+ (consult--async-map #'consult-recoll--transformer)
+ (consult--async-filter #'identity))
+ :annotate #'consult-recoll--annotation
+ :prompt consult-recoll-prompt
+ :require-match t
+ :lookup #'consult--lookup-member
+ :sort nil
+ :state (and (not consult-recoll-inline-snippets)
+ #'consult-recoll--preview)
+ :group (and consult-recoll-group-by-mime
+ #'consult-recoll--group)
+ :initial initial
+ :history '(:input consult-recoll-history)
+ :category 'recoll-result))
+
+;;;###autoload
+(defun consult-recoll (ask)
+ "Consult recoll's local index.
+With prefix argument ASK, the user is prompted for an initial query string."
+ (interactive "P")
+ (let ((initial (when ask
+ (if (stringp ask) ask (read-string "Initial query: ")))))
+ (consult-recoll--open (consult-recoll--search initial))))
+
+;;;###autoload
+(defun consult-recoll-embark-setup ()
+ "Set up integration with embark.
+In particular, allow opening candidates from embark-collect
+buffers."
+ (add-to-list 'embark-default-action-overrides
+ '(recoll-result . consult-recoll--open)))
+
+
+(provide 'consult-recoll)
+;;; consult-recoll.el ends here