branch: externals/denote commit 5e44e8f8bf9e4542f746d4aae0abb264272dfab9 Merge: 9aed12156e f70c96c5ca Author: Protesilaos Stavrou <i...@protesilaos.com> Commit: GitHub <nore...@github.com>
Merge pull request #571 from lmq-10/search-improvements Add some useful features to query buffers --- denote.el | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 263 insertions(+), 11 deletions(-) diff --git a/denote.el b/denote.el index 50d0c65b28..1c268cac4c 100644 --- a/denote.el +++ b/denote.el @@ -2539,17 +2539,21 @@ pass it to `denote-directory-files'." (denote-retrieve-groups-xref-query query files-matching-regexp)) #'string-collate-lessp)) -(defun denote-retrieve-xref-alist (query &optional files-matching-regexp) +(defun denote-retrieve-xref-alist (query &optional files) "Return xref alist of absolute file paths with location of matches for QUERY. -With optional FILES-MATCHING-REGEXP, limit the list of files -accordingly (per `denote-directory-files'). +Optional FILES can be a list of files to search for. It can also be a +regular expression, which means to use the text files in +`denote-directory' that match that regexp. -At all times limit the search to text files." +If FILES is not given, use all text files as returned by +`denote-directory-files'." (let ((xref-file-name-display 'abs)) (xref--analyze (xref-matches-in-files query - (denote-directory-files files-matching-regexp :omit-current :text-only))))) + (if (and files (listp files)) + files + (denote-directory-files files denote-query--omit-current :text-only)))))) ;;;; New note @@ -5100,25 +5104,89 @@ file's title. This has the same meaning as in `denote-link'." 'denote-query-mode "4.0.0") +(declare-function outline-cycle "outline" (&optional event)) +(declare-function outline-cycle-buffer "outline" (&optional level)) +(declare-function outline-next-heading "outline" ()) +(declare-function outline-previous-heading "outline" ()) + +(defvar denote-query-mode-map + (let ((map (make-sparse-keymap))) + (define-key map "a" #'outline-cycle-buffer) + (define-key map "f" #'denote-grep-refine) + (define-key map "k" #'outline-previous-heading) + (define-key map "j" #'outline-next-heading) + (define-key map "o" #'delete-other-windows) + (define-key map "s" #'denote-grep) + (define-key map "v" #'outline-cycle) + (define-key map "x" #'denote-grep-exclude-files) + (define-key map "i" #'denote-grep-only-include-files) + (define-key map "l" #'recenter-current-error) + (define-key map "X" #'denote-grep-exclude-files-with-keywords) + (define-key map "I" #'denote-grep-only-include-files-with-keywords) + (define-key map "G" #'denote-grep-clear-all-filters) + map) + "Keymap for buffers generated by `denote-make-links-buffer'.") + (define-derived-mode denote-query-mode xref--xref-buffer-mode "Denote Query" "Major mode for queries found in the variable `denote-directory'. This is used by the command `denote-backlinks' and all links created by the `denote-query' command, among others." - :interactive nil) + :interactive nil + (require 'outline) + (setq-local outline-minor-mode-use-buttons 'in-margins) + (outline-minor-mode)) (define-obsolete-function-alias 'denote-link--prepare-backlinks 'denote-make-links-buffer "4.0.0") +(defcustom denote-query-format-heading-function #'identity + "Function used to construct headings for files matched by a query. + +It is called with a single argument, the path to the note file, and it +should always return a string." + :type 'function) + +(defcustom denote-query-untitled-string "[Untitled]" + "String to use as heading for untitled notes in links' buffer. + +Used only by `denote-query-extract-title'." + :type 'string) + +(defvar denote-query--last-files nil + "Variable holding files matched by last call to `denote-make-links-buffer'.") + +(defvar denote-query--last-query nil + "Variable holding query string for last call to `denote-make-links-buffer'.") + +(defvar denote-query--omit-current t + "Variable governing whether query should omit current file.") + +(defun denote-query-extract-title (file) + "Extract note title from FILE front matter. + +When no title is found, return title found in FILE name. + +When that doesn't work, return `denote-grep-untitled-string'. + +Intended to be used as `denote-query-format-heading-function'." + (let ((title + (denote-retrieve-title-or-filename + file + (denote-filetype-heuristics file)))) + (if (and (stringp title) (not (string-blank-p title))) + title + denote-query-untitled-string))) + ;; NOTE 2025-03-24: The `&rest' is there because we used to have an ;; extra SHOW-CONTEXT parameter. This way we do not break anybody's ;; code, even if we slightly modify the behaviour. -(defun denote-make-links-buffer (query &optional files-matching-regexp buffer-name display-buffer-action &rest _) +(defun denote-make-links-buffer (query &optional files buffer-name display-buffer-action &rest _) "Create links' buffer called BUFFER-NAME for QUERY. -With optional FILES-MATCHING-REGEXP, limit the list of files -accordingly (per `denote-directory-files'). +Optional FILES can be a list of files to search for. It can also be a +regexp, which limits the files accordingly per `denote-directory-files'. Optional DISPLAY-BUFFER-ACTION is a `display-buffer' action and concomitant alist, such as `denote-backlinks-display-buffer-action'." @@ -5130,10 +5198,29 @@ concomitant alist, such as `denote-backlinks-display-buffer-action'." ;; suitable function and the results would be automatically ;; in relative form, but eventually notes may not be all ;; under a common directory (or project). - (xref-alist (denote-retrieve-xref-alist query files-matching-regexp)) + (xref-alist (denote-retrieve-xref-alist query files)) (dir (denote-directory))) (unless xref-alist (error "No matches for query `%s'" query)) + ;; Update internal variables + (setq denote-query--last-files nil) + (setq denote-query--last-query query) + (dolist (x xref-alist) + (let* ((file-xref (car x)) + (file + ;; NOTE: Unfortunately, the car of the xref construct is + ;; not reliable; sometimes it's absolute, sometimes it + ;; is not + (if (file-name-absolute-p file-xref) + file-xref + (xref-location-group + (xref-match-item-location (car (last x))))))) + ;; Add to current set of files + (push file denote-query--last-files) + ;; Format heading + (setf (car x) (funcall denote-query-format-heading-function file)))) + (delete-dups denote-query--last-files) + ;; Insert results (with-current-buffer (get-buffer-create buffer) (erase-buffer) (denote-query-mode) @@ -5150,13 +5237,178 @@ concomitant alist, such as `denote-backlinks-display-buffer-action'." (setq-local revert-buffer-function (lambda (_ignore-auto _noconfirm) (when-let* ((buffer-file-name file)) - (denote-make-links-buffer query files-matching-regexp buffer-name display-buffer-action))))) + (denote-make-links-buffer query files buffer-name display-buffer-action))))) (display-buffer buffer display-buffer-action))) (defvar denote-query-links-buffer-function #'denote-make-links-buffer "Function to make an Xref buffer showing query link results. It accepts the same arguments as `denote-make-links-buffer'.") +;;;;;; Additional features for searching file contents + +(defvar denote-grep-query-history nil + "Minibuffer history for `denote-grep' commands.") + +(defvar denote-grep-file-regexp-history nil + "Minibuffer history for `denote-grep' commands asking for a file regexp.") + +(defun denote-grep-query-prompt (&optional type) + "Prompt for a search query in the minibuffer. + +The prompt assumes a search in all files, unless TYPE is non-nil. + +For now, the only recognized value for TYPE is :focused (for a focused +search, see `denote-grep-refine'). + +TYPE only affects the prompt, not the returned value. + +Returned value is a list in order to be used in an `interactive' spec." + (list (read-string + (cond ((eq type :focused) + "Search (only files matched last): ") + (t "Search (all Denote files): ")) + nil 'denote-grep-query-history))) + +(defun denote-grep-file-regexp-prompt (&optional include) + "Prompt for a file regexp in the minibuffer. + +The prompt assumes the user wants to exclude files, unless INCLUDE is +non-nil. + +Returned value is a list in order to be used in an `interactive' spec." + (list (read-string + (if (not include) + "Exclude file names matching: " + "Only include file names matching: ") + nil 'denote-grep-file-regexp-history))) + +(defun denote-grep-keywords-prompt (&optional include) + "Prompt for keywords to filter in the minibuffer, with completion. + +Keywords are read using `completing-read-multiple'. + +The prompt assumes the user wants to exclude the keywords, unless +INCLUDE is non-nil. + +Returned value is a list in order to be used in an `interactive' spec." + (list + (delete-dups + (completing-read-multiple + (if (not include) + "Exclude files with keywords: " + "Only include files with keywords: ") + (denote-keywords) nil t nil 'denote-keyword-history)))) + +;;;###autoload +(defun denote-grep (query) + "Search QUERY in the content of Denote files. +QUERY should be a regular expression accepted by `xref-search-program'. + +The files to search for are those returned by `denote-directory-files' +with a non-nil TEXT-ONLY argument. + +Results are put in a buffer which allows folding and further +filtering (see the manual for details). + +You can insert a link to a grep search in any note by using the command +`denote-query-contents-link'." + (interactive (denote-grep-query-prompt)) + (let (denote-query--omit-current) + (denote-make-links-buffer query))) + +(defun denote-grep-refine (query) + "Search QUERY in the content of files which matched the last search. +\"Last search\" here means any call to `denote-backlinks' and all links +created by the `denote-query' command. + +QUERY should be regular expression." + (interactive (denote-grep-query-prompt :focused)) + (denote-make-links-buffer + query denote-query--last-files + nil '(display-buffer-same-window)) + (message "Searching `%s' in files matched previously" query)) + +(defun denote-grep-exclude-files (regexp) + "Exclude files whose name matches REGEXP from current search buffer. + +This is useful even if you don't know regular expressions, given the +Denote file-naming scheme. For instance, to exclude notes with the +keyword \"philosophy\" from current search buffer, type +‘\\<denote-query-mode-map>\\[denote-grep-exclude-files] _philosophy +RET’. + +Internally, this works by generating a new call to +`denote-make-links-buffer' with the same QUERY as the last one, but with +a set of files gotten from checking REGEXP against last matched files. + +When called from Lisp, REGEXP can be a list; in that case, it should be +a list of fixed strings (NOT regexps) to check against last matched +files. Files that match any of the strings get excluded. Internally, +the list is processed using `regexp-opt'. For an example of this usage, +see `denote-grep-exclude-files-with-keywords'." + (interactive (denote-grep-file-regexp-prompt)) + (let (final-files) + (dolist (file denote-query--last-files) + (unless (string-match + ;; Support list of strings as REGEXP + (if (listp regexp) + (regexp-opt regexp) + regexp) + file) + (push file final-files))) + (if final-files + (denote-make-links-buffer denote-query--last-query final-files) + (user-error "No remaining files when applying that filter")) + (message "Excluding files matching `%s'" regexp))) + +(defun denote-grep-only-include-files (regexp) + "Exclude file names not matching REGEXP from current query buffer. + +See `denote-grep-exclude-files' for details, including the behaviour +when REGEXP is a list." + (interactive (denote-grep-file-regexp-prompt :include)) + (let (final-files) + (dolist (file denote-query--last-files) + (when (string-match + ;; Support list of strings as REGEXP + (if (listp regexp) + (regexp-opt regexp) + regexp) + file) + (push file final-files))) + (if final-files + (denote-make-links-buffer denote-query--last-query final-files) + (user-error "No remaining files when applying that filter")) + (message "Only including files matching `%s'" regexp))) + +(defun denote-grep-exclude-files-with-keywords (keywords) + "Exclude files with KEYWORDS from current query buffer. + +KEYWORDS should be a list of keywords (without underscore). + +Interactively, KEYWORDS are read from the minibuffer using +`completing-read-multiple', which see." + (interactive (denote-grep-keywords-prompt)) + (denote-grep-exclude-files + (mapcar (lambda (kw) (concat "_" kw)) keywords))) + +(defun denote-grep-only-include-files-with-keywords (keywords) + "Exclude files without KEYWORDS from current query buffer. + +See `denote-grep-exclude-files-with-keywords' for details." + (interactive (denote-grep-keywords-prompt :include)) + (denote-grep-only-include-files + (mapcar (lambda (kw) (concat "_" kw)) keywords))) + +(defun denote-grep-clear-all-filters () + "Run the last search with the full set of files in `denote-directory'. + +This effectively gets ride of any interactive filter applied (by the +means of e.g. `denote-grep-exclude-files')." + (interactive) + (denote-make-links-buffer denote-query--last-query) + (message "Cleared all filters")) + ;;;;;; Backlinks (defun denote--backlinks-get-buffer-name (file id)