2591 lines
134 KiB
EmacsLisp
2591 lines
134 KiB
EmacsLisp
;;; org-ql.el --- Org Query Language, search command, and agenda-like view -*- lexical-binding: t; -*-
|
||
|
||
;; Copyright (C) 2017-2022 Adam Porter
|
||
|
||
;; Author: Adam Porter <adam@alphapapa.net>
|
||
;; Url: https://github.com/alphapapa/org-ql
|
||
;; Version: 0.8-pre
|
||
;; Package-Requires: ((emacs "26.1") (dash "2.18.1") (f "0.17.2") (map "2.1") (org "9.0") (org-super-agenda "1.2") (ov "1.0.6") (peg "1.0.1") (s "1.12.0") (transient "0.1") (ts "0.2-pre"))
|
||
;; Keywords: hypermedia, outlines, Org, agenda
|
||
|
||
;;; 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 <https://www.gnu.org/licenses/>.
|
||
|
||
;;; Commentary:
|
||
|
||
;; `org-ql' is a Lispy query language for Org files. It allows you to
|
||
;; find Org entries matching certain criteria and return a list of
|
||
;; them or perform actions on them. Commands are also provided which
|
||
;; display a buffer with matching results, similar to an Org Agenda
|
||
;; buffer.
|
||
|
||
;;; Code:
|
||
|
||
;;;; Requirements
|
||
|
||
(require 'cl-lib)
|
||
(require 'org)
|
||
(require 'org-duration)
|
||
(require 'org-element)
|
||
(require 'org-habit)
|
||
(require 'seq)
|
||
(require 'subr-x)
|
||
|
||
(require 'dash)
|
||
(require 'map)
|
||
(require 'ts)
|
||
|
||
;;;; Constants
|
||
|
||
;; Note the use of the `rx' `blank' keyword, which matches "horizontal" whitespace.
|
||
|
||
(defconst org-ql-tsr-regexp-inactive
|
||
(concat org-ts-regexp-inactive "\\(--?-?"
|
||
org-ts-regexp-inactive "\\)?")
|
||
;; MAYBE: Propose this for org.el.
|
||
"Regular expression matching an inactive timestamp or timestamp range.")
|
||
|
||
(defconst org-ql-clock-regexp
|
||
(rx bol (0+ blank) "CLOCK:" (group (1+ not-newline)))
|
||
"Regular expression matching Org \"CLOCK:\" lines.
|
||
Like `org-clock-line-re', but matches the timestamp range in a
|
||
match group.")
|
||
|
||
(defconst org-ql-planning-regexp
|
||
(rx bol (0+ blank) (or "CLOSED" "DEADLINE" "SCHEDULED") ":" (1+ blank) (group (1+ not-newline)))
|
||
"Regular expression matching Org \"planning\" lines.
|
||
That is, \"CLOSED:\", \"DEADLINE:\", or \"SCHEDULED:\".")
|
||
|
||
(defconst org-ql-tag-line-re
|
||
"^\\*+ \\(?:.*[ \t]\\)?\\(:\\([[:alnum:]_@#%:]+\\):\\)[ \t]*$"
|
||
;; Copied from `org-tag-line-re' from org.el.
|
||
"Regexp matching tags in a headline.
|
||
Tags are stored in match group 1. Match group 2 stores the tags
|
||
without the enclosing colons.")
|
||
|
||
(defvaralias 'org-ql-link-regexp
|
||
;; FIXME: `org-link-bracket-re' is void until `org-link-make-regexps' is called.
|
||
(if (bound-and-true-p org-link-bracket-re)
|
||
'org-link-bracket-re
|
||
'org-bracket-link-regexp)
|
||
"Regexp used to match Org bracket links.
|
||
Necessary because of changes in Org 9.something.")
|
||
|
||
(defconst org-ql-link-description-group
|
||
(if (bound-and-true-p org-link-bracket-re)
|
||
2
|
||
3)
|
||
;; I wish Org would not introduce backward-incompatible changes like this in
|
||
;; minor releases. It requires awkward workarounds to be maintained for years.
|
||
"Regexp match group used to extract description from Org bracket links.
|
||
Necessary because of backward-incompatible changes in Org
|
||
9.something: when `org-link-bracket-re' was added,
|
||
`org-bracket-link-regexp' was marked as an obsolete alias for it,
|
||
but the match groups were changed, so they are not compatible.")
|
||
|
||
;;;; Variables
|
||
|
||
(defvar org-ql--today nil)
|
||
|
||
(defvar org-ql-use-preamble t
|
||
;; MAYBE: Naming things is hard. There must be a better term than "preamble."
|
||
"Use query preambles to speed up searches.
|
||
May be disabled for debugging, benchmarks, etc.")
|
||
|
||
(defvar org-ql-cache (make-hash-table :weakness 'key)
|
||
;; IIUC, setting weakness to `key' means that, when a buffer is closed,
|
||
;; its entries will be removed from this table at the next GC.
|
||
"Query cache, keyed by buffer.
|
||
Each value is a list of the buffer's modified tick and another
|
||
hash table, keyed by arguments passed to
|
||
`org-ql--select-cached'.")
|
||
|
||
(defvar org-ql-tags-cache (make-hash-table :weakness 'key)
|
||
"Per-buffer tags cache.
|
||
Keyed by buffer. Each value is a cons of the buffer's modified
|
||
tick, and another hash table keyed on buffer position, whose
|
||
values are a list of two lists, inherited tags and local tags, as
|
||
strings.")
|
||
|
||
(defvar org-ql-node-value-cache (make-hash-table :weakness 'key)
|
||
"Per-buffer node cache.
|
||
Keyed by buffer. Each value is a cons of the buffer's modified
|
||
tick, and another hash table keyed on buffer position, whose
|
||
values are alists in which the key is a function and the value is
|
||
the value returned by it at that node.")
|
||
|
||
(eval-and-compile
|
||
(defvar org-ql-predicates
|
||
;; FIXME: Is this remapping still necessary? It was mapping `org-back-to-heading'
|
||
;; to itself until now, so maybe I broke it and it doesn't matter anymore.
|
||
(list (cons 'org-back-to-heading (list :name 'org-back-to-heading :fn (symbol-function 'outline-back-to-heading))))
|
||
"Plist of predicates, their corresponding functions, and their docstrings.
|
||
This list should not contain any duplicates."))
|
||
|
||
;;;;; Timestamp regexps
|
||
|
||
;; We need more specificity than the built-in Org timestamp regexps
|
||
;; provide, and sometimes they change from version to version, so we
|
||
;; define our own. And by defining them with `rx', they are much
|
||
;; easier to understand than the string-based ones in org.el (of
|
||
;; course, `rx' probably wasn't available when most of those were
|
||
;; written).
|
||
|
||
;; MAYBE: Use newer `rx' custom expressions to define these.
|
||
;; MAYBE: Add match groups corresponding to the ones in the "official" Org regexps.
|
||
;; TODO: Use these new regexps in more places.
|
||
|
||
(defvar org-ql-regexp-part-ts-date
|
||
(rx (repeat 4 digit) "-" (repeat 2 digit) "-" (repeat 2 digit)
|
||
;; Day of week
|
||
(optional " " (1+ alpha)))
|
||
"Matches the inner, date part of an Org timestamp, both active and inactive.
|
||
Also matches optional day-of-week. Used to build other timestamp
|
||
regexps.")
|
||
|
||
(defvar org-ql-regexp-part-ts-repeaters
|
||
;; Repeaters (not sure if the colon is necessary, but it's in the org.el one)
|
||
(rx (repeat 1 2 (seq " " (repeat 1 2 (any "-+:.")) (1+ digit) (any "hdwmy")
|
||
(optional "/" (1+ digit) (any "hdwmy")))))
|
||
"Matches the repeater part of an Org timestamp.
|
||
Includes leading space character.")
|
||
|
||
(defvar org-ql-regexp-part-ts-time
|
||
(rx " " (repeat 1 2 digit) ":" (repeat 2 digit))
|
||
"Matches the inner, time part of an Org timestamp (i.e. HH:MM).
|
||
Includes leading space character. Used to build other timestamp
|
||
regexps.")
|
||
|
||
;; NOTE: The inactive timestamp regexps don't allow repeaters. I don't know if this is
|
||
;; officially correct, but it seems to make sense, and would be easy to change if necessary.
|
||
|
||
(defvar org-ql-regexp-ts-both
|
||
(rx-to-string
|
||
`(or (seq "<" (regexp ,org-ql-regexp-part-ts-date)
|
||
(optional (regexp ,org-ql-regexp-part-ts-time))
|
||
(optional (regexp ,org-ql-regexp-part-ts-repeaters)) ">")
|
||
(seq "[" (regexp ,org-ql-regexp-part-ts-date)
|
||
(optional (regexp ,org-ql-regexp-part-ts-time)))))
|
||
"Matches both active and inactive Org timestamps, with or without time.")
|
||
|
||
(defvar org-ql-regexp-ts-both-with-time
|
||
(rx-to-string `(or (seq "<" (regexp ,org-ql-regexp-part-ts-date)
|
||
(regexp ,org-ql-regexp-part-ts-time)
|
||
(optional (regexp ,org-ql-regexp-part-ts-repeaters)) ">")
|
||
(seq "[" (regexp ,org-ql-regexp-part-ts-date)
|
||
(regexp ,org-ql-regexp-part-ts-time) "]")))
|
||
"Matches both active and inactive Org timestamps, with time.")
|
||
|
||
(defvar org-ql-regexp-ts-both-without-time
|
||
(rx-to-string `(or (seq "<" (regexp ,org-ql-regexp-part-ts-date)
|
||
(optional (regexp ,org-ql-regexp-part-ts-repeaters)) ">")
|
||
(seq "[" (regexp ,org-ql-regexp-part-ts-date) "]")))
|
||
"Matches both active and inactive Org timestamps, without time.")
|
||
|
||
(defvar org-ql-regexp-ts-active
|
||
(rx-to-string `(seq "<" (regexp ,org-ql-regexp-part-ts-date)
|
||
(optional (regexp ,org-ql-regexp-part-ts-time))
|
||
(optional (regexp ,org-ql-regexp-part-ts-repeaters)) ">"))
|
||
"Matches active Org timestamps, with or without time.")
|
||
|
||
(defvar org-ql-regexp-ts-active-with-time
|
||
(rx-to-string `(seq "<" (regexp ,org-ql-regexp-part-ts-date)
|
||
(regexp ,org-ql-regexp-part-ts-time)
|
||
(optional (regexp ,org-ql-regexp-part-ts-repeaters)) ">"))
|
||
"Matches active Org timestamps, with time.")
|
||
|
||
(defvar org-ql-regexp-ts-active-without-time
|
||
(rx-to-string `(seq "<" (regexp ,org-ql-regexp-part-ts-date)
|
||
(optional (regexp ,org-ql-regexp-part-ts-repeaters)) ">"))
|
||
"Matches active Org timestamps, without time.")
|
||
|
||
(defvar org-ql-regexp-ts-inactive
|
||
(rx-to-string `(seq "[" (regexp ,org-ql-regexp-part-ts-date)
|
||
(optional (regexp ,org-ql-regexp-part-ts-time))"]"))
|
||
"Matches inactive Org timestamps, with or without time.")
|
||
|
||
(defvar org-ql-regexp-ts-inactive-with-time
|
||
(rx-to-string `(seq "[" (regexp ,org-ql-regexp-part-ts-date)
|
||
(regexp ,org-ql-regexp-part-ts-time)"]"))
|
||
"Matches inactive Org timestamps, with time.")
|
||
|
||
(defvar org-ql-regexp-ts-inactive-without-time
|
||
(rx-to-string `(seq "[" (regexp ,org-ql-regexp-part-ts-date) "]"))
|
||
"Matches inactive Org timestamps, without time.")
|
||
|
||
(defvar org-ql-regexp-planning
|
||
(rx-to-string `(seq bow (or (seq "CLOSED" ":" (0+ " ")
|
||
(group-n 1 (regexp ,org-ql-regexp-ts-inactive)))
|
||
(seq (or "DEADLINE" "SCHEDULED") ":" (0+ " ")
|
||
(group-n 1 (regexp ,org-ql-regexp-ts-active))))))
|
||
"Matches CLOSED, DEADLINE or SCHEDULED keyword with timestamp.
|
||
Matches with or without time.")
|
||
|
||
(defvar org-ql-regexp-planning-with-time
|
||
(rx-to-string `(seq bow (or (seq "CLOSED" ":" (0+ " ")
|
||
(group-n 1 (regexp ,org-ql-regexp-ts-inactive-with-time)))
|
||
(seq (or "DEADLINE" "SCHEDULED") ":" (0+ " ")
|
||
(group-n 1 (regexp ,org-ql-regexp-ts-active-with-time))))))
|
||
"Matches CLOSED, DEADLINE or SCHEDULED keyword with timestamp, with time.")
|
||
|
||
(defvar org-ql-regexp-planning-without-time
|
||
(rx-to-string `(seq bow (or (seq "CLOSED" ":" (0+ " ")
|
||
(group-n 1 (regexp ,org-ql-regexp-ts-inactive-without-time)))
|
||
(seq (or "DEADLINE" "SCHEDULED") ":" (0+ " ")
|
||
(group-n 1 (regexp ,org-ql-regexp-ts-active-without-time))))))
|
||
"Matches CLOSED, DEADLINE or SCHEDULED keyword with timestamp, without time.")
|
||
|
||
(defvar org-ql-regexp-deadline
|
||
(rx-to-string `(seq bow "DEADLINE" ":" (0+ " ")
|
||
(group (regexp ,org-ql-regexp-ts-active))))
|
||
"Matches DEADLINE keyword with a time-and-hour stamp, with or without time.")
|
||
|
||
(defvar org-ql-regexp-deadline-with-time
|
||
(rx-to-string `(seq bow "DEADLINE" ":" (0+ " ")
|
||
(group (regexp ,org-ql-regexp-ts-active-with-time))))
|
||
"Matches DEADLINE keyword with a time-and-hour stamp, with time.")
|
||
|
||
(defvar org-ql-regexp-deadline-without-time
|
||
(rx-to-string `(seq bow "DEADLINE" ":" (0+ " ")
|
||
(group (regexp ,org-ql-regexp-ts-active-without-time))))
|
||
"Matches DEADLINE keyword with a time-and-hour stamp, without time.")
|
||
|
||
(defvar org-ql-regexp-scheduled
|
||
(rx-to-string `(seq bow "SCHEDULED" ":" (0+ " ")
|
||
(group (regexp ,org-ql-regexp-ts-active))))
|
||
"Matches SCHEDULED keyword with a time-and-hour stamp, with or without time.")
|
||
|
||
(defvar org-ql-regexp-scheduled-with-time
|
||
(rx-to-string `(seq bow "SCHEDULED" ":" (0+ " ")
|
||
(group (regexp ,org-ql-regexp-ts-active-with-time))))
|
||
"Matches SCHEDULED keyword with a time-and-hour stamp, with time.")
|
||
|
||
(defvar org-ql-regexp-scheduled-without-time
|
||
(rx-to-string `(seq bow "SCHEDULED" ":" (0+ " ")
|
||
(group (regexp ,org-ql-regexp-ts-active-without-time))))
|
||
"Matches SCHEDULED keyword with a time-and-hour stamp, without time.")
|
||
|
||
;;;; Customization
|
||
|
||
(defgroup org-ql nil
|
||
"Customization for `org-ql'."
|
||
:group 'org
|
||
:link '(custom-manual "(org-ql)Usage")
|
||
:link '(url-link "https://github.com/alphapapa/org-ql"))
|
||
|
||
(defcustom org-ql-ask-unsafe-queries t
|
||
"Ask before running a query that could run arbitrary code.
|
||
Org QL queries in sexp form can contain arbitrary expressions.
|
||
When opening an \"org-ql-search:\" link or updating a dynamic
|
||
block that contains a query in sexp form, and this option is
|
||
non-nil, the user will be prompted for confirmation before
|
||
opening the link.
|
||
|
||
This variable may be set file-locally to disable this warning in
|
||
files that the user assumes are safe (e.g. of known provenance).
|
||
Users who are entirely unconcerned about this issue may disable
|
||
the option globally (at their own risk, however minimal it
|
||
probably is).
|
||
|
||
See Info node `(org-ql)Queries'."
|
||
:type 'boolean
|
||
:risky t)
|
||
|
||
(defcustom org-ql-default-predicate 'rifle
|
||
"Predicate used for plain-string tokens without a specified predicate."
|
||
:type '(choice (const heading)
|
||
(const heading-regexp)
|
||
(const regexp)
|
||
(const rifle)
|
||
(const smart)
|
||
(const outline-path)
|
||
(const outline-path-segment)))
|
||
|
||
;;;; Functions
|
||
|
||
;;;;; Query execution
|
||
|
||
(define-hash-table-test 'org-ql-hash-test #'equal (lambda (args)
|
||
(sxhash-equal (prin1-to-string args))))
|
||
|
||
;;;###autoload
|
||
(cl-defun org-ql-select (buffers-or-files query &key action narrow sort)
|
||
"Return items matching QUERY in BUFFERS-OR-FILES.
|
||
|
||
BUFFERS-OR-FILES is a file or buffer, a list of files and/or
|
||
buffers, or a function which returns such a list.
|
||
|
||
QUERY is an `org-ql' query sexp (quoted, since this is a
|
||
function).
|
||
|
||
ACTION is a function which is called on each matching entry with
|
||
point at the beginning of its heading. It may be:
|
||
|
||
- `element' or nil: Equivalent to `org-element-headline-parser'.
|
||
|
||
- `element-with-markers': Equivalent to calling
|
||
`org-element-headline-parser', with markers added using
|
||
`org-ql--add-markers'. Suitable for formatting with
|
||
`org-ql-view--format-element', allowing insertion into an Org
|
||
Agenda-like buffer.
|
||
|
||
- A sexp, which will be byte-compiled into a lambda function.
|
||
|
||
- A function symbol.
|
||
|
||
If NARROW is non-nil, buffers are not widened (the default is to
|
||
widen and search the entire buffer).
|
||
|
||
SORT is either nil, in which case items are not sorted; or one or
|
||
a list of defined `org-ql' sorting methods (`date', `deadline',
|
||
`scheduled', `closed', `todo', `priority', `reverse', or `random'); or a
|
||
user-defined comparator function that accepts two items as
|
||
arguments and returns nil or non-nil. Sorting methods are
|
||
applied in the order given (i.e. later methods override earlier
|
||
ones), and `reverse' may be used more than once.
|
||
|
||
For example, `(date priority)' would present items with the
|
||
highest priority first, and within each priority the oldest items
|
||
would appear first. In contrast, `(date reverse priority)' would
|
||
also present items with the highest priority first, but within
|
||
each priority the newest items would appear first."
|
||
(declare (indent defun))
|
||
(-let* ((buffers (->> (cl-typecase buffers-or-files
|
||
(null (list (current-buffer)))
|
||
(function (funcall buffers-or-files))
|
||
(list buffers-or-files)
|
||
(otherwise (list buffers-or-files)))
|
||
(--map (cl-etypecase it
|
||
;; NOTE: This etypecase is essential to opening links safely,
|
||
;; as it rejects, e.g. lambdas in the buffers-files argument.
|
||
(buffer it)
|
||
(string (or (find-buffer-visiting it)
|
||
(when (file-readable-p it)
|
||
;; It feels unintuitive that `find-file-noselect' returns
|
||
;; a buffer if the filename doesn't exist.
|
||
(find-file-noselect it))
|
||
(display-warning 'org-ql-select (format "Can't open file: %s" it) :error)))))
|
||
;; Ignore special/hidden buffers.
|
||
(--remove (string-prefix-p " " (buffer-name it)))))
|
||
(query (org-ql--normalize-query query))
|
||
((&plist :query :preamble :preamble-case-fold) (org-ql--query-preamble query))
|
||
(predicate (org-ql--query-predicate query))
|
||
(action (pcase action
|
||
;; NOTE: These two lambdas are backquoted to prevent "unused lexical
|
||
;; variable" warnings from byte-compilation, because they don't use
|
||
;; all of the variables from their enclosing scope.
|
||
('element-with-markers (byte-compile
|
||
`(lambda (&rest _ignore)
|
||
(org-ql--add-markers
|
||
(org-element-headline-parser (line-end-position))))))
|
||
((or 'nil 'element) (byte-compile
|
||
`(lambda (&rest _ignore)
|
||
(org-element-headline-parser (line-end-position)))))
|
||
((pred functionp) action)
|
||
((and (pred listp) (guard (or (special-form-p (car action))
|
||
(macrop (car action))
|
||
(functionp (car action)))))
|
||
(byte-compile
|
||
`(lambda (&rest _ignore)
|
||
,action)))
|
||
(_ (user-error "Invalid action form: %s" action))))
|
||
(org-ql--today (ts-now))
|
||
(items (let (orig-fns)
|
||
(unwind-protect
|
||
(progn
|
||
(--each org-ql-predicates
|
||
;; Set predicate functions.
|
||
(-let (((&plist :name :fn) (cdr it)))
|
||
;; Save original function.
|
||
(push (list :name name :fn (symbol-function name)) orig-fns)
|
||
;; Temporarily set new function definition.
|
||
(fset name fn)))
|
||
;; Run query on buffers.
|
||
(->> buffers
|
||
(--map (with-current-buffer it
|
||
(unless (derived-mode-p 'org-mode)
|
||
(display-warning 'org-ql-select (format "Not an Org buffer: %s" (buffer-name)) :error))
|
||
(org-ql--select-cached :query query :preamble preamble :preamble-case-fold preamble-case-fold
|
||
:predicate predicate :action action :narrow narrow)))
|
||
(-flatten-n 1)))
|
||
(--each orig-fns
|
||
;; Restore original function mappings.
|
||
(-let (((&plist :name :fn) it))
|
||
(fset name fn)))))))
|
||
;; Sort items
|
||
(pcase sort
|
||
(`nil items)
|
||
((guard (cl-subsetp (-list sort) '(date deadline scheduled closed todo priority random reverse)))
|
||
;; Default sorting functions
|
||
(org-ql--sort-by items (-list sort)))
|
||
;; Sort by user-given comparator.
|
||
((pred functionp) (-sort sort items))
|
||
(_ (user-error "SORT must be either nil, one or a list of the defined sorting methods (see documentation), or a comparison function of two arguments")))))
|
||
|
||
;;;###autoload
|
||
(cl-defun org-ql-query (&key (select 'element-with-markers) from where narrow order-by)
|
||
"Like `org-ql-select', but arguments are named more like a SQL query.
|
||
|
||
SELECT corresponds to the `org-ql-select' argument ACTION. It is
|
||
the function called on matching headings, the results of which
|
||
are returned by this function. It may be:
|
||
|
||
- `element' or nil: Equivalent to `org-element-headline-parser'.
|
||
|
||
- `element-with-markers': Equivalent to
|
||
`org-element-headline-parser', with markers added using
|
||
`org-ql--add-markers'. Suitable for formatting with
|
||
`org-ql-view--format-element', allowing insertion into an Org
|
||
Agenda-like buffer.
|
||
|
||
- A sexp, which will be byte-compiled into a lambda function.
|
||
|
||
- A function symbol.
|
||
|
||
FROM corresponds to the `org-ql-select' argument BUFFERS-OR-FILES.
|
||
It may be one or a list of file paths and/or buffers.
|
||
|
||
WHERE corresponds to the `org-ql-select' argument QUERY. It
|
||
should be an `org-ql' query sexp.
|
||
|
||
ORDER-BY corresponds to the `org-ql-select' argument SORT, which
|
||
see.
|
||
|
||
NARROW corresponds to the `org-ql-select' argument NARROW."
|
||
(declare (indent 0))
|
||
(org-ql-select from where
|
||
:action select
|
||
:narrow narrow
|
||
:sort order-by))
|
||
|
||
(defun org-ql--select-cached (&rest args)
|
||
"Return results for ARGS and current buffer using cache."
|
||
;; MAYBE: Timeout cached queries. Probably not necessarily since they will be removed when a
|
||
;; buffer is closed, or when a query is run after modifying a buffer.
|
||
(-let* (((&plist :query :preamble :action :narrow :preamble-case-fold) args)
|
||
(query-cache-key
|
||
;; The key must include the preamble, because some queries are replaced by
|
||
;; the preamble, leaving a nil query, which would make the key ambiguous.
|
||
(list :query query :preamble preamble :action action :preamble-case-fold preamble-case-fold
|
||
(if narrow
|
||
;; Use bounds of narrowed portion of buffer.
|
||
(cons (point-min) (point-max))
|
||
nil))))
|
||
(if-let* ((buffer-cache (gethash (current-buffer) org-ql-cache))
|
||
(query-cache (cadr buffer-cache))
|
||
(modified-tick (car buffer-cache))
|
||
(buffer-unmodified-p (eq (buffer-chars-modified-tick) modified-tick))
|
||
(cached-result (gethash query-cache-key query-cache)))
|
||
(pcase cached-result
|
||
('org-ql-nil nil)
|
||
(_ cached-result))
|
||
(let ((new-result (apply #'org-ql--select args)))
|
||
(cond ((or (not query-cache)
|
||
(not buffer-unmodified-p))
|
||
(puthash (current-buffer)
|
||
(list (buffer-chars-modified-tick)
|
||
(let ((table (make-hash-table :test 'org-ql-hash-test)))
|
||
(puthash query-cache-key (or new-result 'org-ql-nil) table)
|
||
table))
|
||
org-ql-cache))
|
||
(t (puthash query-cache-key (or new-result 'org-ql-nil) query-cache)))
|
||
new-result))))
|
||
|
||
(cl-defun org-ql--select (&key preamble preamble-case-fold predicate action narrow
|
||
&allow-other-keys)
|
||
"Return results for given arguments.
|
||
Return results of mapping function ACTION across entries in
|
||
current buffer matching function PREDICATE. If NARROW is
|
||
non-nil, buffer will not be widened.
|
||
|
||
PREAMBLE may be a regexp to search for before calling PREDICATE.
|
||
When doing so, `case-fold-search' is bound to
|
||
PREAMBLE-CASE-FOLD."
|
||
;; Since the mappings are stored in the variable `org-ql-predicates', macros like `flet'
|
||
;; can't be used, so we do it manually (this is same as the equivalent `flet' expansion).
|
||
;; Mappings are stored in the variable because it allows predicates to be defined with a
|
||
;; macro, which allows documentation to be easily generated for them.
|
||
(save-excursion
|
||
(save-restriction
|
||
(unless narrow
|
||
(widen))
|
||
(goto-char (point-min))
|
||
(when (org-before-first-heading-p)
|
||
(outline-next-heading))
|
||
(if (not (org-at-heading-p))
|
||
(progn
|
||
;; No headings in buffer: return nil.
|
||
(unless (string-prefix-p " " (buffer-name))
|
||
;; Not a special, hidden buffer: show message, because if a user accidentally
|
||
;; searches a buffer without headings, he might be confused.
|
||
(message "org-ql: No headings in buffer: %s" (current-buffer)))
|
||
nil)
|
||
;; Find matching entries.
|
||
;; TODO: Bind `case-fold-search' around the preamble loop.
|
||
(cond (preamble (cl-loop while (let ((case-fold-search preamble-case-fold))
|
||
(re-search-forward preamble nil t))
|
||
do (outline-back-to-heading 'invisible-ok)
|
||
when (funcall predicate)
|
||
collect (funcall action)
|
||
do (outline-next-heading)))
|
||
(t (cl-loop when (funcall predicate)
|
||
collect (funcall action)
|
||
while (outline-next-heading))))))))
|
||
|
||
;;;;; Helpers
|
||
|
||
(defun org-ql--ensure-buffer (file-or-buffer)
|
||
"Ensure a buffer is named or visiting FILE-OR-BUFFER.
|
||
If no such buffer exists with the name, and it is the name of a
|
||
readable file, `find-file-noselect' it into a buffer."
|
||
;; See comment in `org-ql-find'.
|
||
;; FIXME: Use this in `helm-org-ql' the same way it's used in
|
||
;; `org-ql-find'.
|
||
(unless (or (get-buffer file-or-buffer)
|
||
(find-buffer-visiting file-or-buffer))
|
||
(if (file-readable-p file-or-buffer)
|
||
(with-current-buffer (find-file-noselect file-or-buffer)
|
||
(cl-assert (eq 'org-mode major-mode) nil (format "Not an Org buffer: %S" file-or-buffer)))
|
||
(display-warning 'org-ql (format "Not a readable file: %S" file-or-buffer) :error))))
|
||
|
||
(defun org-ql--tags-at (position)
|
||
;; FIXME: This function actually assumes that point is already at POSITION.
|
||
"Return tags for POSITION in current buffer.
|
||
Returns cons (INHERITED-TAGS . LOCAL-TAGS)."
|
||
;; I'd like to use `-if-let*', but it doesn't leave non-nil variables
|
||
;; bound in the else clause, so destructured variables that are non-nil,
|
||
;; like found caches, are not available in the else clause.
|
||
(if-let* ((buffer-cache (gethash (current-buffer) org-ql-tags-cache))
|
||
(modified-tick (car buffer-cache))
|
||
(tags-cache (cdr buffer-cache))
|
||
(buffer-unmodified-p (eq (buffer-chars-modified-tick) modified-tick))
|
||
(cached-result (gethash position tags-cache)))
|
||
;; Found in cache: return them.
|
||
;; FIXME: Isn't `cached-result' a list of (INHERITED . LOCAL)? It
|
||
;; will never be just `org-ql-nil', but the CAR and CDR may be, so
|
||
;; they need to each be checked and replaced with nil if necessary.
|
||
(pcase cached-result
|
||
('org-ql-nil nil)
|
||
(_ cached-result))
|
||
;; Not found in cache: get tags and cache them.
|
||
(let* ((local-tags (or (when (looking-at org-ql-tag-line-re)
|
||
(split-string (match-string-no-properties 2) ":" t))
|
||
'org-ql-nil))
|
||
(inherited-tags (or (when org-use-tag-inheritance
|
||
(save-excursion
|
||
(if (org-up-heading-safe)
|
||
;; Return parent heading's tags.
|
||
(-let* (((inherited local) (org-ql--tags-at (point)))
|
||
(tags (when (or inherited local)
|
||
(cond ((and (listp inherited)
|
||
(listp local))
|
||
(->> (append inherited local)
|
||
-non-nil -uniq))
|
||
((listp inherited) inherited)
|
||
((listp local) local)))))
|
||
(cl-typecase org-use-tag-inheritance
|
||
(list (setf tags (-intersection tags org-use-tag-inheritance)))
|
||
(string (setf tags (--select (string-match org-use-tag-inheritance it)
|
||
tags))))
|
||
(pcase org-tags-exclude-from-inheritance
|
||
('nil tags)
|
||
(_ (-difference tags org-tags-exclude-from-inheritance))))
|
||
;; Top-level heading: use file tags.
|
||
org-file-tags)))
|
||
'org-ql-nil))
|
||
(all-tags (list inherited-tags local-tags)))
|
||
;; Check caches again, because they may have been set now.
|
||
;; TODO: Is there a clever way we could avoid doing this, or is it inherently necessary?
|
||
(setf buffer-cache (gethash (current-buffer) org-ql-tags-cache)
|
||
modified-tick (car buffer-cache)
|
||
tags-cache (cdr buffer-cache)
|
||
buffer-unmodified-p (eq (buffer-chars-modified-tick) modified-tick))
|
||
(unless (and buffer-cache buffer-unmodified-p)
|
||
;; Buffer-local tags cache empty or invalid: make new one.
|
||
(setf tags-cache (make-hash-table))
|
||
(puthash (current-buffer)
|
||
(cons (buffer-chars-modified-tick) tags-cache)
|
||
org-ql-tags-cache))
|
||
(puthash position all-tags tags-cache))))
|
||
|
||
(defun org-ql--outline-path ()
|
||
"Return outline path for heading at point."
|
||
(save-excursion
|
||
(let ((heading (save-match-data
|
||
(if (looking-at org-complex-heading-regexp)
|
||
(or (match-string 4) "")
|
||
""))))
|
||
(if (org-up-heading-safe)
|
||
;; MAYBE: It seems wrong to call the cache function from
|
||
;; inside this function, like a violation of separation of
|
||
;; concern. Can this be rewritten to not work that way?
|
||
(append (org-ql--value-at (point) #'org-ql--outline-path)
|
||
(list heading))
|
||
(list heading)))))
|
||
|
||
;; TODO: Use --value-at for tags cache.
|
||
|
||
(defun org-ql--value-at (position fn)
|
||
;; TODO: Either rename to `value-at-point' and remove `position' arg, or move point.
|
||
"Return FN's value at POSITION in current buffer.
|
||
Values compared with `equal'."
|
||
;; I'd like to use `-if-let*', but it doesn't leave non-nil variables
|
||
;; bound in the else clause, so destructured variables that are non-nil,
|
||
;; like found caches, are not available in the else clause.
|
||
(pcase (if-let* ((buffer-cache (gethash (current-buffer) org-ql-node-value-cache))
|
||
(modified-tick (car buffer-cache))
|
||
(position-cache (cdr buffer-cache))
|
||
(buffer-unmodified-p (eq (buffer-chars-modified-tick) modified-tick))
|
||
(value-cache (gethash position position-cache))
|
||
(cached-value (alist-get fn value-cache nil nil #'equal)))
|
||
;; Found in cache: return it.
|
||
cached-value
|
||
;; Not found in cache: call FN, cache and return its value.
|
||
(let ((new-value (or (funcall fn) 'org-ql-nil)))
|
||
;; Check caches again, because it may have been set now, e.g. by
|
||
;; recursively going up an outline tree.
|
||
;; TODO: Is there a clever way we could avoid doing this, or is it inherently necessary?
|
||
(setf buffer-cache (gethash (current-buffer) org-ql-node-value-cache)
|
||
modified-tick (car buffer-cache)
|
||
position-cache (cdr buffer-cache)
|
||
value-cache (when position-cache
|
||
(gethash position position-cache))
|
||
buffer-unmodified-p (eq (buffer-chars-modified-tick) modified-tick))
|
||
(unless (and buffer-cache buffer-unmodified-p)
|
||
;; Buffer-local node cache empty or invalid: make new one.
|
||
(setf position-cache (make-hash-table)
|
||
value-cache (gethash position position-cache))
|
||
(puthash (current-buffer)
|
||
(cons (buffer-chars-modified-tick) position-cache)
|
||
org-ql-node-value-cache))
|
||
(setf (alist-get fn value-cache nil nil #'equal) new-value)
|
||
(puthash position value-cache position-cache)
|
||
new-value))
|
||
;; Return nil or the non-nil value.
|
||
('org-ql-nil nil)
|
||
(else else)))
|
||
|
||
(defun org-ql--add-markers (element)
|
||
"Return ELEMENT with Org marker text properties added.
|
||
ELEMENT should be an Org element like that returned by
|
||
`org-element-headline-parser'. This function should be called
|
||
from within ELEMENT's buffer."
|
||
;; NOTE: `org-agenda-new-marker' works, until it doesn't, because...I don't know. It sometimes
|
||
;; raises errors or returns markers that don't point into a buffer. `copy-marker' always works,
|
||
;; of course, but maybe it will leave "dangling" markers, which could affect performance over
|
||
;; time? I don't know, but for now, it seems that we have to use `copy-marker'.
|
||
(let* ((marker (copy-marker (org-element-property :begin element)))
|
||
(properties (--> (cadr element)
|
||
(plist-put it :org-marker marker)
|
||
(plist-put it :org-hd-marker marker))))
|
||
(setf (cadr element) properties)
|
||
element))
|
||
|
||
(defun org-ql--ask-unsafe-query (query)
|
||
"Signal an error if user rejects running QUERY.
|
||
If `org-ql-view-ask-unsafe-links' is nil, does nothing and
|
||
returns nil."
|
||
(when org-ql-ask-unsafe-queries
|
||
(let ((query-string (propertize (cl-etypecase query
|
||
(list (prin1-to-string query))
|
||
(string query))
|
||
'face 'font-lock-warning-face)))
|
||
(unless (yes-or-no-p (concat "Query is in sexp form and could contain arbitrary code: "
|
||
query-string " Execute it? "))
|
||
(user-error "Query aborted by user")))))
|
||
|
||
(defun org-ql--plist-get* (plist property)
|
||
"Return the value of PROPERTY in PLIST, or `not-found'.
|
||
Returns `not-found' if the property is missing."
|
||
(if-let ((pair (plist-member plist property)))
|
||
(cadr pair)
|
||
'not-found))
|
||
|
||
;;;;; Query processing
|
||
|
||
;; Processing, compiling, etc. for queries.
|
||
|
||
;; This error is used for when compiling a query signals an error,
|
||
;; making it easier for the UI to avoid spurious warnings, e.g. for
|
||
;; partially typed queries in the Helm commands.
|
||
(define-error 'org-ql-invalid-query "Invalid Org QL query" 'user-error)
|
||
|
||
(defun org-ql--coalesce-ands (query)
|
||
"Return QUERY having coalesced any AND'ed clauses' predicates.
|
||
Multiple calls to the same predicate within an `and' expression
|
||
are coalesced into a single call to the predicate.
|
||
|
||
Note that this is a relatively simple function which does not
|
||
comprehensively coalesce every call that could be. For example,
|
||
if QUERY contained four calls to the `src' predicate with two
|
||
unique language arguments, only the calls for one language would
|
||
be coalesced."
|
||
;; TODO: Use a per-predicate alist-getting function that accounts
|
||
;; for arguments which must be unique...maybe...someday...
|
||
|
||
;; NOTE: This implentation can sometimes reorder sub-expressions,
|
||
;; like:
|
||
;;
|
||
;; (and (src :regexps ("foo") :lang "elisp") (src :regexps ("bar")))
|
||
;;
|
||
;; becomes:
|
||
;;
|
||
;; (and (src :regexps ("bar")) (src :regexps ("foo") :lang "elisp"))
|
||
;;
|
||
;; because the first one could be coalescable, but the second one
|
||
;; can't be coalesced with it since they don't specify the same
|
||
;; language. That could be fixed, but it's probably not worth it.
|
||
(cl-labels ((rec (sexp)
|
||
(pcase sexp
|
||
(`(,(and boolean (or 'or 'not)) . ,sexps)
|
||
`(,boolean ,@(mapcar #'rec sexps)))
|
||
(`(and . ,sexps)
|
||
(anded sexps))
|
||
(_ sexp)))
|
||
(anded (sexps)
|
||
(let (anded-predicates new-sexp)
|
||
(dolist (sexp sexps)
|
||
(pcase sexp
|
||
(`(,(or 'or 'not) . ,_)
|
||
(push (rec sexp) new-sexp))
|
||
(`(,predicate . ,args)
|
||
(pcase-exhaustive (plist-get (alist-get predicate org-ql-predicates) :coalesce)
|
||
(`nil (push sexp new-sexp))
|
||
(`t (setf (alist-get predicate anded-predicates)
|
||
(append (alist-get predicate anded-predicates) args)))
|
||
((and fn (pred functionp))
|
||
(if-let (new-args (funcall fn (alist-get predicate anded-predicates) args))
|
||
(setf (alist-get predicate anded-predicates) new-args)
|
||
(push sexp new-sexp)))))))
|
||
(delq nil `(and ,@(nreverse new-sexp) ,@(nreverse anded-predicates))))))
|
||
(rec query)))
|
||
|
||
(defun org-ql--sanity-check-form (form)
|
||
"Signal error if any forms in FORM do not have preconditions met.
|
||
Or, when possible, fix the problem."
|
||
(cl-flet ((check (symbol)
|
||
(pcase symbol
|
||
('done (unless org-done-keywords
|
||
;; NOTE: This check needs to be done from within the Org buffer being checked.
|
||
(error "Variable `org-done-keywords' is nil. Are you running this from an Org buffer?"))))))
|
||
(cl-loop for elem in form
|
||
if (consp elem)
|
||
do (progn
|
||
(check (car elem))
|
||
(org-ql--sanity-check-form (cdr elem)))
|
||
else do (check elem))))
|
||
|
||
(cl-defun org-ql--link-regexp (&key description-or-target description target)
|
||
"Return a regexp matching Org links according to arguments.
|
||
Each argument is treated as a regexp (so non-regexp strings
|
||
should be quoted before being passed to this function). If
|
||
DESCRIPTION-OR-TARGET, match it in either description or target.
|
||
If DESCRIPTION, match it in the description. If TARGET, match it
|
||
in the target. If both DESCRIPTION and TARGET, match both,
|
||
respectively."
|
||
;; This `rx' part is borrowed from `org-make-link-regexps'. It matches the interior of an
|
||
;; Org link target (i.e. the parts between the brackets, including any escaped brackets).
|
||
(let ((link-target-part '(0+ (or (not (any "[]\\"))
|
||
(and "\\" (0+ "\\\\") (any "[]"))
|
||
(and (1+ "\\") (not (any "[]")))))))
|
||
(cl-labels
|
||
((no-desc
|
||
(match) (rx-to-string `(seq (or bol (1+ blank))
|
||
"[[" ,link-target-part (regexp ,match) ,link-target-part
|
||
"]]")))
|
||
(match-both
|
||
(description target)
|
||
(rx-to-string `(seq (or bol (1+ blank))
|
||
"[[" ,link-target-part (regexp ,target) ,link-target-part
|
||
"][" (*? anything) (regexp ,description) (*? anything)
|
||
"]]")))
|
||
;; Note that these actually allow empty descriptions
|
||
;; or targets, depending on what they are matching.
|
||
(match-desc
|
||
(match) (rx-to-string `(seq (or bol (1+ blank))
|
||
"[[" ,link-target-part
|
||
"][" (*? anything) (regexp ,match) (*? anything)
|
||
"]]")))
|
||
(match-target
|
||
(match) (rx-to-string `(seq (or bol (1+ blank))
|
||
"[[" ,link-target-part (regexp ,match) ,link-target-part
|
||
"][" (*? anything)
|
||
"]]"))))
|
||
(cond (description-or-target
|
||
(rx-to-string `(or (regexp ,(no-desc description-or-target))
|
||
(regexp ,(match-desc description-or-target))
|
||
(regexp ,(match-target description-or-target)))))
|
||
((and description target)
|
||
(match-both description target))
|
||
(description (match-desc description))
|
||
(target (rx-to-string `(or (regexp ,(no-desc target))
|
||
(regexp ,(match-target target)))))))))
|
||
|
||
(defun org-ql--format-src-block-regexp (&optional lang)
|
||
"Return regexp equivalent to `org-babel-src-block-regexp' with LANG filled in."
|
||
;; I couldn't find a way to match block contents without the regexp
|
||
;; also matching past the end of the block and into later blocks. Even
|
||
;; using `minimal-match' in several different combinations didn't work.
|
||
;; So matching contents will have to be done with the predicate.
|
||
(rx-to-string `(seq bol (group (zero-or-more (any " ")))
|
||
"#+begin_src"
|
||
(one-or-more (any " "))
|
||
,(or lang `(1+ (not (any " \n\f
|
||
"))))
|
||
(zero-or-more (any " "))
|
||
(group (or (seq (zero-or-more (not (any "\n\":")))
|
||
"\""
|
||
(zero-or-more (not (any "\n\"*")))
|
||
"\""
|
||
(zero-or-more (not (any "\n\":"))))
|
||
(zero-or-more (not (any "\n\":")))))
|
||
(group (zero-or-more (not (any "\n")))) "\n"
|
||
(63 (group (*\? (not (any " |