Files
nursery/lisp/org-roam-dblocks.el

523 lines
21 KiB
EmacsLisp
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;; org-roam-dblocks.el --- Defines dynamic block types for org-roam -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Chris Barrett
;; Author: Chris Barrett <chris+emacs@walrus.cool>
;; Homepage: https://github.com/chrisbarrett/nursery
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Defines dynamic block types for use with org-roam.
;;
;; Example configuration:
;;
;; (use-package org-roam-dblocks
;; :hook (org-mode . org-roam-dblocks-autoupdate-mode))
;; The dblock types defined are:
;;
;; - "backlinks": lists the backlinks for this node, with optional filter
;; criteria.
;;
;; E.g., in my TV Tropes note I have:
;;
;; #+BEGIN: backlinks :match trope$
;; - [[id:...][Advanced Ancient Humans Trope]]
;; - [[id:...][Bizarre Alien Biology Trope]]
;; - [[id:...][Evil Brit Trope]]
;; - [[id:...][Humans Are Bastards Trope]]
;; - [[id:...][Lost Superweapon Trope]]
;; - [[id:...][Mega-Corporations Trope]]
;; - [[id:...][One-Man-Army Trope]]
;; - [[id:...][Precursor Alien Civilisation Trope]]
;; - [[id:...][Scary Dogmatic Aliens Trope]]
;; - [[id:...][Sealed Evil in a Can Trope]]
;; #+END:
;;
;; - "notes": lists org-roam notes based on filter criteria.
;;
;; E.g. A block that collects open questions in my Zettelkasten:
;;
;; #+BEGIN: notes :match (rx "?" eos) :tags (-answered -snooze -outline)
;; - [[id:...][Are Alien and Blade Runner in the same universe?]]
;; - [[id:...][Can attention span be increased through training?]]
;; - [[id:...][Is there research supporting the claimed benefits of the Pomodoro Technique?]]
;; #+END:
;; Options:
;;
;; - :only-missing, when non-nil, excludes links already in the node from the
;; block.
;;
;; As a use-case, you might use this to search for nodes that haven't been
;; worked into the text of the current node.
;;
;; - :include-same-file, when non-nil, includes backlinks from nodes within the
;; same file.
;; Implemented filters:
;;
;; - :match, which matches note titles (case-insensitively).
;; A match filter must be an `rx' form or regexp string. String
;; double-quotes may be safely omitted for regexps that are just a single
;; alphanumeric word.
;;
;; Examples:
;; - foo, "foo", (rx "foo")
;; - "foo bar", (rx "foo bar")
;; - "[?]$", (rx "?" eol)
;;
;; If the match contains a capture group, the text in that group is used for
;; the link description. E.g., to extract the text after "Prefix - ", you'd
;; write one of:
;; - :match "^Prefix - \\(.+\\)"
;; - :match (rx bol "Prefix - " (group (+ nonl)))
;;
;; - :tags, which matches the note's headline and file tags.
;;
;; A tags filter must be a single tag (double-quotes optional) or a list of
;; tags. Each tag may be preceded by a minus sign to indicate a forbidden tag,
;; or a plus symbol to indicate a required tag. Tags are interpreted to be
;; required if neither +/- is specified.
;;
;; Examples of tags matches:
;; - required: foo, "foo", +foo, "+foo"
;; - forbidden: -foo, "-foo"
;; - multiple tags (and-ed together): (foo "+bar" -baz)
;;
;; - :filter, and its logical opposite :remove, provide a generic way to decide
;; which nodes to include.
;;
;; A filter can be a symbol, which is interpreted to be a function name, a
;; lambda expression, or a bare S-expression.
;;
;; When a function or lambda expression is provided, it will be called on
;; each node to decide whether to include that node in results. The given
;; function should accept a single argument, which is an `org-roam-node'.
;;
;; Otherwise, the form is interpreted to be an 'anaphoric' S-expression,
;; where the symbol `it' is bound to an `org-roam-node', before being
;; evaluated.
;;
;; Examples:
;; - my-predicate
;; - (lambda (node) (zerop (org-roam-node-level node)))
;; - (zerop (org-roam-node-level it))
;;
;; For convenience, the slots on an org-roam node are bound within an
;; anaphoric predicate. This allows you to rewrite:
;;
;; (zerop (org-roam-node-level it))
;;
;; As the more convenient:
;;
;; (zerop level)
;;
;; If :filter and :remove are both provided, they are logically and-ed.
;;
;; - :forbidden-ids, a list of node IDs (strings) that should always be excluded
;; from results.
;; Keeping blocks up-to-date:
;;
;; These dynamic blocks can optionally be updated when opening and saving
;; buffers. To do this, enable `org-roam-dblocks-autoupdate-mode'.
;;
;; The autoupdate can be customised using `org-roam-dblocks-auto-refresh-tags'
;; so that it only runs in files/headings with specific tags. This is useful if
;; you want to have both index-style cards and stable canned searches.
;;
;;; Code:
(require 'dash)
(require 'org-tags-filter)
(require 'plisty)
(require 's)
(cl-eval-when (compile)
(require 'org)
(require 'org-roam))
(defgroup org-roam-dblocks nil
"Adds support for a dynamic block of org-roam backlinks to `org-mode'."
:group 'productivity
:prefix "org-roam-dblocks-")
(defcustom org-roam-dblocks-auto-refresh-tags nil
"A list of tags (as strings) or nil.
If non-nil, only org-roam nodes with the specified tags have
their blocks updated automatically."
:group 'org-roam-dblocks
:type '(choice (const nil)
(repeat :tag "Tag" (string))))
(defcustom org-roam-dblocks-autoupdate-silently-p t
"Whether to suppress messages during the dblock update process."
:group 'org-roam-dblocks
:type 'boolean)
(defconst org-roam-dblocks-names '("notes" "backlinks"))
(plisty-define org-roam-dblocks-link
:required (:id :desc))
(defun org-roam-dblocks--make-list-item-formatter (params)
(let* ((indent (org-roam-dblocks-args-indent params))
(prefix (concat (make-string indent ?\ ) "- ")))
(lambda (link)
(concat prefix
(org-link-make-string (concat "id:" (org-roam-dblocks-link-id link))
(org-roam-dblocks-link-desc link))))))
(defalias 'org-roam-dblocks--link-sorting
(-on #'string-lessp (-compose #'downcase #'org-roam-dblocks-link-desc)))
(plisty-define org-roam-dblocks-args
:optional (:id :match :tags :only-missing :include-same-file
:name :indentation-column :content :forbidden-ids
:filter :remove :indent))
(defun org-roam-dblocks--make-link-formatter (params)
(let ((regexp-parser (when-let* ((matcher (org-roam-dblocks-args-match params)))
(org-roam-dblocks--parse-regexp-form matcher))))
(lambda (node)
(let ((title (org-roam-node-title node)))
(org-roam-dblocks-link-create :id (org-roam-node-id node)
:desc (or (when regexp-parser
(cadr (s-match regexp-parser title)))
title))))))
(defun org-roam-dblocks--parse-regexp-form (form)
;;; Quick tests:
;; (org-roam-dblocks--parse-regexp-form nil)
;; (org-roam-dblocks--parse-regexp-form 'hi)
;; (org-roam-dblocks--parse-regexp-form "hi")
;; (org-roam-dblocks--parse-regexp-form '(rx bol "hi" eol))
(cond
((null form) nil)
((stringp form)
(unless (zerop (length form))
form))
((symbolp form)
(symbol-name form))
(t
(pcase form
(`(rx . ,args)
(rx-to-string (cons 'and args)
t))))))
(defconst org-roam-dblocks--node-slot-symbols
'(file file-title file-hash file-atime file-mtime
id level point todo priority scheduled deadline title properties olp
tags aliases refs)
"A list of slots names on org-roam-nodes.
This list is used to create lexical bindings in anaphoric
predicates.")
(defun org-roam-dblocks--bindings-for-lexical-scope (node)
(cons `(it . ,node)
(seq-map (lambda (sym)
(let ((slot-accessor (intern (format "org-roam-node-%s" sym))))
(cons sym (funcall slot-accessor node))))
org-roam-dblocks--node-slot-symbols)))
(defun org-roam-dblocks--parse-filter-fn (keyword form)
;; Quick tests:
;; (org-roam-dblocks--parse-filter-fn :foo nil)
;; (org-roam-dblocks--parse-filter-fn :foo t)
;; (org-roam-dblocks--parse-filter-fn :foo 'ignore)
;; (org-roam-dblocks--parse-filter-fn :foo (lambda (node) node))
;; (org-roam-dblocks--parse-filter-fn :foo '(lambda (node) node))
;; (org-roam-dblocks--parse-filter-fn :foo 'it)
;; (org-roam-dblocks--parse-filter-fn :foo '(equal it 0))
(cl-macrolet ((lambda-with-error-handling (binding &rest body)
`(lambda ,binding
(condition-case-unless-debug err
(progn ,@body)
(error
(error "Error evaluating %s form: %s"
keyword
(error-message-string err)))))))
(cond
((null form)
nil)
((functionp form)
(lambda-with-error-handling (node)
(funcall form node)))
(t
(lambda-with-error-handling (node)
(eval form (org-roam-dblocks--bindings-for-lexical-scope node)))))))
(defun org-roam-dblocks--compile-filter-fns (params)
;; Quick tests:
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter (equal "foo" it))) "foo")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter (equal "foo" it))) "bar")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:remove (equal "foo" it))) "foo")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:remove (equal "foo" it))) "bar")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter stringp :remove stringp)) "foo")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter stringp :remove integerp)) "foo")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter stringp :remove integerp)) 0)
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter integerp :remove stringp)) "foo")
;; (funcall (org-roam-dblocks--compile-filter-fns '(:filter integerp :remove stringp)) 1)
(pcase-exhaustive
(cons (org-roam-dblocks--parse-filter-fn :filter (org-roam-dblocks-args-filter params))
(org-roam-dblocks--parse-filter-fn :remove (org-roam-dblocks-args-remove params)))
(`(nil . nil) (-const t))
(`(,filter . nil) filter)
(`(nil . ,remove) (-not remove))
(`(,filter . ,remove) (-andfn filter (-not remove)))))
(defun org-roam-dblocks--eval-regexp-predicate (node match)
(or (null match)
(string-match-p match (org-roam-node-title node))))
(defun org-roam-dblocks--eval-tags-predicate (node tags-filter)
(let* ((tags (org-roam-node-tags node))
(forbidden-tags (org-tags-filter-forbidden tags-filter))
(required-tags (org-tags-filter-required tags-filter)))
(not (or (seq-intersection tags forbidden-tags)
(seq-difference required-tags tags)))))
(defun org-roam-dblocks--compiled-predicates (params)
(let ((tags (org-tags-filter-parse (org-roam-dblocks-args-tags params)))
(match (org-roam-dblocks--parse-regexp-form (org-roam-dblocks-args-match params)))
(predicate (org-roam-dblocks--compile-filter-fns params))
(file-for-id (org-roam-node-file (org-roam-node-from-id (org-roam-dblocks-args-id params)))))
(lambda (node)
(when (and (not (seq-contains-p (org-roam-dblocks-args-forbidden-ids params)
(org-roam-node-id node)))
(or (org-roam-dblocks-args-include-same-file params)
(not (equal file-for-id (org-roam-node-file node))))
(org-roam-dblocks--eval-regexp-predicate node match)
(org-roam-dblocks--eval-tags-predicate node tags)
(funcall predicate node))
node))))
(defun org-roam-dblocks--links-not-in-dblocks (node)
(let ((forward-links (org-roam-db-query [:select :distinct [pos dest]
:from links
:where (and (= type "id") (= source $s1))]
(org-roam-node-id node)))
(not-in-block (make-hash-table :test 'equal)))
(with-temp-buffer
(insert-file-contents (org-roam-node-file node))
(let ((org-inhibit-startup t)
(org-inhibit-logging t))
(delay-mode-hooks (org-mode)))
(pcase-dolist (`(,pos ,id) forward-links)
(goto-char pos)
(let ((block-args (cadr (org-element-lineage (org-element-at-point) '(dynamic-block)))))
(unless (or block-args
(seq-contains-p org-roam-dblocks-names (plist-get block-args :block-name)))
(puthash id t not-in-block)))))
(hash-table-keys not-in-block)))
(defun org-roam-dblocks--compute-forbidden-ids (params)
(append (list (org-roam-dblocks-args-id params))
(org-roam-dblocks-args-forbidden-ids params)
(when (org-roam-dblocks-args-only-missing params)
(when-let* ((node (org-roam-node-from-id (org-roam-dblocks-args-id params))))
(org-roam-dblocks--links-not-in-dblocks node)))))
;; HACK: To avoid dirtying the buffer when blocks haven't changed, we actually
;; compute the data to insert earlier, at the phase where org would normally
;; blindly clear out the block's content. We then check whether the block
;; content needs to be updated.
(define-advice org-prepare-dblock (:around (fn &rest args) org-roam-dblocks-dirty-checks)
"Advice to hack org's dblock update flow for the dblock types we define.
FN is the advised function, and ARGS are its arguments.
Populates `org-roam-dblocks--content' and ensures the buffer
stays unchanged if there's no difference between the new content
and old content."
(unless (looking-at org-dblock-start-re)
(user-error "Not at a dynamic block"))
(let ((name (match-string-no-properties 1)))
(if (not (member name org-roam-dblocks-names))
;; Defer to default implementation for any dblocks we don't define in
;; this file..
(apply fn args)
(let* ((indent (save-excursion
(goto-char (match-beginning 0))
(back-to-indentation)
(current-column)))
(node-id (ignore-errors
(save-match-data
(org-roam-node-id (org-roam-node-at-point)))))
(params (append (list :name name)
(read (concat "(" (match-string 3) ")"))
(list :id node-id :indent indent)))
(content-start (match-end 0))
(content-end (if (re-search-forward org-dblock-end-re nil t)
(1- (match-beginning 0))
(error "Dynamic block not terminated")))
(current-content (buffer-substring-no-properties content-start content-end))
(updated-content
(condition-case-unless-debug err
(pcase-exhaustive name
("notes" (org-roam-dblocks-format-notes params))
("backlinks" (org-roam-dblocks-format-backlinks params)))
(error
(error-message-string err))))
(content-changed-p (not (equal current-content
updated-content)))
(params (append params (list :new-content (when content-changed-p updated-content)))))
;; Only clear the block if the content should change.
(when content-changed-p
(delete-region content-start content-end)
(goto-char content-start))
params))))
;;;###autoload
(defun org-roam-dblocks--write-content (params)
(when-let* ((new-content (plist-get params :new-content)))
(insert "\n")
(insert new-content)))
;;; Backlinks dblock type
(defun org-roam-dblocks-format-backlinks (params)
(org-roam-dblocks-args-assert params t)
(setf (plist-get params :forbidden-ids)
(org-roam-dblocks--compute-forbidden-ids params))
(if-let* ((id (org-roam-dblocks-args-id params))
(node (if id (org-roam-node-from-id id) (org-roam-node-at-point t)))
(lines (->> (org-roam-backlinks-get node :unique t)
(-keep (-compose (org-roam-dblocks--compiled-predicates params) #'org-roam-backlink-source-node))
(seq-map (org-roam-dblocks--make-link-formatter params))
(seq-sort 'org-roam-dblocks--link-sorting)
(seq-map (org-roam-dblocks--make-list-item-formatter params)))))
(string-join lines "\n")
""))
;;;###autoload
(defalias 'org-dblock-write:backlinks #'org-roam-dblocks--write-content)
;;;###autoload
(defun org-insert-dblock:backlinks ()
"Insert a dynamic block backlinks at point."
(interactive)
(atomic-change-group
(org-create-dblock (list :name "backlinks")))
(org-update-dblock))
(org-dynamic-block-define "backlinks" #'org-insert-dblock:backlinks)
;;; Roam notes search dblock type
(defun org-roam-dblocks-format-notes (params)
(org-roam-dblocks-args-assert params t)
(cl-assert (or (org-roam-dblocks-args-match params)
(org-roam-dblocks-args-tags params)
(org-roam-dblocks-args-filter params)
(org-roam-dblocks-args-remove params))
t "Must provide at least one of :tags, :match, :filter or :remove")
(setf (plist-get params :forbidden-ids)
(org-roam-dblocks--compute-forbidden-ids params))
(let ((lines (->> (org-roam-node-list)
(-keep (org-roam-dblocks--compiled-predicates params))
(seq-map (org-roam-dblocks--make-link-formatter params))
(seq-sort #'org-roam-dblocks--link-sorting)
(seq-map (org-roam-dblocks--make-list-item-formatter params)))))
(string-join lines "\n")))
;;;###autoload
(defalias 'org-dblock-write:notes #'org-roam-dblocks--write-content)
(defun org-roam-dblocks--read-tags-filter-for-dblock-args ()
(let* ((tags-filter (org-tags-filter-read))
(unpacked (append (seq-map (lambda (it) (concat "-" it)) (org-tags-filter-forbidden tags-filter))
(org-tags-filter-required tags-filter))))
(if (equal 1 (length unpacked))
(car unpacked)
unpacked)))
;;;###autoload
(defun org-insert-dblock:notes ()
"Insert a dynamic block org-roam notes at point."
(interactive)
(let ((args (pcase-exhaustive (completing-read "Query Type: " '("Title Regexp Match" "Tags Filter"))
("Title Regexp Match"
(list :match (read-string "Match title (regexp): ")))
("Tags Filter"
(list :tags (org-roam-dblocks--read-tags-filter-for-dblock-args))))))
(atomic-change-group
(org-create-dblock (append '(:name "notes") args))))
(org-update-dblock))
(org-dynamic-block-define "notes" #'org-insert-dblock:notes)
(defun org-roam-dblocks--update-block-at-point-p ()
(when (derived-mode-p 'org-mode)
(or (null org-roam-dblocks-auto-refresh-tags)
(seq-intersection org-roam-dblocks-auto-refresh-tags
(append org-file-tags (org-get-tags))))))
(defun org-roam-dblocks--update-blocks ()
(let ((message-log-max (if org-roam-dblocks-autoupdate-silently-p nil message-log-max)))
(org-map-dblocks
(lambda ()
(when (org-roam-dblocks--update-block-at-point-p)
(pcase (org-element-at-point)
(`(dynamic-block ,plist)
(when (member (plist-get plist :block-name) org-roam-dblocks-names)
(org-update-dblock)))))))))
;;;###autoload
(define-minor-mode org-roam-dblocks-autoupdate-mode
"Automatically update org-roam-dblocks blocks on open and save."
:init-value nil
(cond
(org-roam-dblocks-autoupdate-mode
(org-roam-dblocks--update-blocks)
(when (and (buffer-file-name) (buffer-modified-p))
(let ((message-log-max (if org-roam-dblocks-autoupdate-silently-p nil message-log-max)))
(save-buffer)))
(add-hook 'before-save-hook #'org-roam-dblocks--update-blocks nil t))
(t
(remove-hook 'before-save-hook #'org-roam-dblocks--update-blocks))))
(provide 'org-roam-dblocks)
;;; org-roam-dblocks.el ends here