It's Macros, Functions, and Property Lists…Oh My!
I wrote Ever Further Refinements of Org Roam Usage. In that post I talked about what I was implementing and why. I’m writing about the implementation details.
After writing Ever Further Refinements of Org Roam Usage, I spent a bit of time refactoring the code. I put that code up as a gist on Github.
You can see the history of the refactoring, albeit without comments.
One result of the refactoring is that the menus now look a bit different. But the principle remain the same.
The Lists to Define Subjects
First, let’s start with the jnf/org-roam-capture-templates-plist
. I created a Property List, or plist
, for all of my org-roam templates.
Property list jnf/org-roam-capture-templates-plist
implementation
(setq jnf/org-roam-capture-templates-plist
(list
:hesburgh-libraries
'("h" "Hesburgh Libraries" plain "%?"
:if-new
(file+head
"hesburgh-libraries/%---${slug}.org"
"#+title: ${title}\n#+FILETAGS: :hesburgh: %^G\n\n")
:unnarrowed t)
:jf-consulting
'("j" "JF Consulting" plain "%?"
:if-new
(file+head
"jeremy-friesen-consulting/%---${slug}.org"
"#+title: ${title}\n#+FILETAGS: :personal:jeremy-friesen-consulting: %^G\n\n")
:unnarrowed t)
:personal
'("p" "Personal" plain "%?"
:if-new
(file+head
"personal/%---${slug}.org"
"#+title: ${title}\n#+FILETAGS: :personal: %^G\n\n")
:unnarrowed t)
:personal-encrypted
'("P" "Personal (Encrypted)" plain "%?"
:if-new
(file+head
"personal/%---${slug}.org.gpg"
"#+title: ${title}\n#+FILETAGS: :personal:encrypted: %^G\n\n")
:unnarrowed t)
:public
'("u" "Public" plain "%?"
:if-new
(file+head
"public/%---${slug}.org"
"#+title: ${title}\n#+FILETAGS: :public: %^G\n\n")
:unnarrowed t)
:thel-sector
'("t" "Thel Sector" plain "%?"
:if-new
(file+head
"personal/thel-sector/%---${slug}.org"
"#+title: ${title}\n#+FILETAGS: :thel-sector: %^G\n\n")
:unnarrowed t)
))
With the above, I have a symbolic name for each template. I can then use lookup functions to retrieve the implementation details.
I then created a plist
for the subjects (e.g., jnf/org-roam-capture-subjects-plist
). Each subject is itself a plist
.
Property list jnf/org-roam-capture-subjects-plist
implementation
(setq jnf/org-roam-capture-subjects-plist
(list
;; The :all subject is different from the other items.
:all (list
;; Iterate through all registered capture templates and
;; generate a list
:templates (-non-nil (seq-map-indexed (lambda (template index)
(when (evenp index) template))
jnf/org-roam-capture-templates-plist))
:name "all"
:title "All"
:group "All"
:prefix "a"
:path-to-todo "~/git/org/todo.org")
:jf-consulting (list
:templates (list :jf-consulting)
:name "jf-consulting"
:title "JF Consulting"
:group "Projects"
:prefix "j"
:path-to-todo "~/git/org/jeremy-friesen-consulting/todo.org")
:hesburgh-libraries (list
:templates (list :hesburgh-libraries)
:name "hesburgh-libraries"
:title "Hesburgh Libraries"
:group "Projects"
:prefix "h"
:path-to-todo "~/git/org/hesburgh-libraries/todo.org")
:personal (list
:templates (list :personal :personal-encrypted)
:name "personal"
:title "Personal"
:group "Life"
:prefix "p"
:path-to-todo "~/git/org/personal/todo.org")
:public (list
:templates (list :public)
:name "public"
:title "Public"
:group "Life"
:prefix "u"
:path-to-todo "~/git/org/public/todo.org")
:thel-sector (list
:templates (list :thel-sector)
:name "thel-sector"
:title "Thel Sector"
:group "Projects"
:prefix "t"
:path-to-todo "~/git/org/personal/thel-sector/todo.org")
))
The jnf/org-roam-capture-subjects-plist
plist
contains the various org-roam subjects. Each subject is a plist
with the following properties:
:templates
- A list of named templates available for this subject. See
jnf/org-roam-capture-templates-plist
for list of valid templates. :name
- A string version of the subject, suitable for creating function names.
:title
- The human readable "title-case" form of the subject.
:group
- Used for appending to the "All" menu via
pretty-hydra-define+
. :prefix
- Used for the prefix key when mapping functions to key bindings for
pretty-hydra-define+
. :path-to-todo
- The path to the todo file for this subject.
Functions to Help Build the Hydra Menus
I wrote the jnf/org-roam-templates-for-subject
function to retrieve a subject’s Org-roam 🔍 templates.
Function jnf/org-roam-templates-for-subject
implementation
(cl-defun jnf/org-roam-templates-for-subject (subject
&key
(subjects-plist jnf/org-roam-capture-subjects-plist)
(template-definitions-plist jnf/org-roam-capture-templates-plist))
"Return a list of \`org-roam' templates for the given SUBJECT.
Use the given (or default) SUBJECTS-PLIST to fetch from the
given (or default) TEMPLATE-DEFINITIONS-PLIST."
(let ((templates (plist-get (plist-get subjects-plist subject) :templates)))
(-map (lambda (template) (plist-get template-definitions-plist template))
templates)))
I then created jnf/org-subject-menu–all
, a pretty-hydra-define
menu.
Pretty-hydra-define jnf/org-subject-menu--all
implementation
(defvar jnf/org-subject-menu--title (with-faicon "book" "Org Subject Menu" 1 -0.05))
(pretty-hydra-define jnf/org-subject-menu--all (:foreign-keys warn :title jnf/org-subject-menu--title :quit-key "q" :exit t)
(
;; Note: This matches at least one of the :groups in \`jnf/org-roam-capture-subjects-plist'
"Personal / Public"
()
;; Note: This matches at least one of the :groups in \`jnf/org-roam-capture-subjects-plist'
"Projects"
()
"Org Mode"
(("@" (lambda ()
(interactive)
(find-file (file-truename (plist-get (plist-get jnf/org-roam-capture-subjects-plist :all) :path-to-todo))))
"Todo…")
("+" jnf/org-roam--all--capture "Capture…")
("!" jnf/org-roam--all--node-insert " ├─ Insert…")
("?" jnf/org-roam--all--node-find " └─ Find…")
("/" org-roam-buffer-toggle "Toggle Buffer")
("#" jnf/toggle-roam-subject-filter "Toggle Default Filter")
)))
The jnf/org-subject-menu–all
frames out the menu structure. The menu has three columns: “Personal / Public”, “Projects”, and “Org Mode”. The “Personal / Public” and “Projects” are the two named groups I assigned each subject in the jnf/org-roam-capture-subjects-plist
.
In the above implementation, they start as empty lists. But as we move down the implementation, we’ll append the subjects to those empty lists.
The Macro That Populates the Hydra Menu
Now we get to the create-org-roam-subject-fns-for
macro that does the heavy lifting.
Macro create-org-roam-subject-fns-for
impelementation.
(cl-defmacro create-org-roam-subject-fns-for (subject
&key
(subjects-plist jnf/org-roam-capture-subjects-plist))
"Define the org roam SUBJECT functions and create & update hydra menus.
The functions are wrappers for `org-roam-capture',
`org-roam-node-find', `org-roam-node-insert', and `find-file'.
Create a subject specific `pretty-define-hydra' and append to the
`jnf/org-subject-menu–all' hydra via the `pretty-define-hydra+'
macro.
Fetch the given SUBJECT from the given SUBJECTS-PLIST."
(let* ((subject-plist (plist-get subjects-plist subject))
(subject-as-symbol subject)
(subject-title (plist-get subject-plist :title))
(subject-name (plist-get subject-plist :name))
;; For todo related antics
(todo-fn-name (intern (concat "jnf/find-file--" subject-name "--todo")))
(path-to-todo (plist-get subject-plist :path-to-todo))
(todo-docstring (concat "Find the todo file for " subject-name " subject."))
;; For hydra menu related antics
(hydra-fn-name (intern (concat "jnf/org-subject-menu--" subject-name)))
(hydra-menu-title (concat subject-title " Subject Menu"))
(hydra-todo-title (concat subject-title " Todo…"))
(hydra-group (plist-get subject-plist :group))
(hydra-prefix (plist-get subject-plist :prefix))
(hydra-kbd-prefix-todo (concat hydra-prefix " @"))
(hydra-kbd-prefix-capture (concat hydra-prefix " +"))
(hydra-kbd-prefix-insert (concat hydra-prefix " !"))
(hydra-kbd-prefix-find (concat hydra-prefix " ?"))
;; For \`org-roam-capture' related antics
(capture-fn-name (intern (concat "jnf/org-roam--" subject-name "--capture")))
(capture-docstring (concat "As \`org-roam-capture' but scoped to " subject-name
".\n\nArguments GOTO and KEYS see \`org-capture'."))
;; For \`org-roam-insert-node' related antics
(insert-fn-name (intern (concat "jnf/org-roam--" subject-name "--node-insert")))
(insert-docstring (concat "As \`org-roam-insert-node' but scoped to " subject-name " subject."))
;; For \`org-roam-find-node' related antics
(find-fn-name (intern (concat "jnf/org-roam--" subject-name "--node-find")))
(find-docstring (concat "As \`org-roam-find-node' but scoped to "
subject-name " subject."
"\n\nArguments INITIAL-INPUT and OTHER-WINDOW are from \`org-roam-find-mode'."))
)
\`(progn
(defun ,todo-fn-name ()
,todo-docstring
(interactive)
(find-file (file-truename ,path-to-todo)))
(defun ,capture-fn-name (&optional goto keys)
,capture-docstring
(interactive "P")
(org-roam-capture goto
keys
:filter-fn (lambda (node) (-contains-p (org-roam-node-tags node) ,subject-name))
:templates (jnf/org-roam-templates-for-subject ,subject-as-symbol)))
(defun ,insert-fn-name ()
,insert-docstring
(interactive)
(org-roam-node-insert (lambda (node) (-contains-p (org-roam-node-tags node) ,subject-name))
:templates (jnf/org-roam-templates-for-subject ,subject-as-symbol)))
(defun ,find-fn-name (&optional other-window initial-input)
,find-docstring
(interactive current-prefix-arg)
(org-roam-node-find other-window
initial-input
(lambda (node) (-contains-p (org-roam-node-tags node) ,subject-name))
:templates (jnf/org-roam-templates-for-subject ,subject-as-symbol)))
;; Create a hydra menu for the given subject
(pretty-hydra-define ,hydra-fn-name (:foreign-keys warn :title jnf/org-subject-menu--title :quit-key "q" :exit t)
(
,hydra-menu-title
(
("@" ,todo-fn-name ,hydra-todo-title)
("+" ,capture-fn-name " ├─ Capture…")
("!" ,insert-fn-name " ├─ Insert…")
("?" ,find-fn-name " └─ Find…")
("/" org-roam-buffer-toggle "Toggle Buffer")
("#" jnf/toggle-roam-subject-filter "Toggle Filter…")
)))
;; Append the following menu items to the \`jnf/org-subject-menu--all'
(pretty-hydra-define+ jnf/org-subject-menu--all()
(,hydra-group
(
(,hydra-kbd-prefix-todo ,todo-fn-name ,hydra-todo-title)
(,hydra-kbd-prefix-capture ,capture-fn-name " ├─ Capture…")
(,hydra-kbd-prefix-insert ,insert-fn-name " ├─ Insert…")
(,hydra-kbd-prefix-find ,find-fn-name " └─ Find…")
)))
)))
The create-org-roam-subject-fns-for
macro does six things for the given subject:
- Creates a function to
find-file
of the subject’s todo. - Creates a subject specific capture function that wraps
org-roam-capture
. - Creates a subject specific insert function that wraps
org-roam-node-insert
. - Creates a subject specific find function that wraps
org-roam-node-find
. - Uses
pretty-hydra-define
to create a subject specific menu. - Uses
pretty-hydra-define+
to append menu items to thejnf/org-subject-menu–all
menu.
Calling the Macro to Populate the Menu
I then call the create-org-roam-subject-fns-for
macro for each of the subjects, except for the :all
subject.
(create-org-roam-subject-fns-for :personal)
(create-org-roam-subject-fns-for :public)
(create-org-roam-subject-fns-for :hesburgh-libraries)
(create-org-roam-subject-fns-for :jf-consulting)
(create-org-roam-subject-fns-for :thel-sector)
The Function and Aliases that Allow for Setting the Subject
Because I didn’t call the create-org-roam-subject-fns-for
macro for the :all
subject, I create some aliases.
(defalias 'jnf/org-roam--all--node-insert 'org-roam-node-insert)
(defalias 'jnf/org-roam--all--node-find 'org-roam-node-find)
(defalias 'jnf/org-roam--all--capture 'org-roam-capture)
In creating these aliases, I reduce the need for complicated logic switching in the jnf/toggle-roam-subject-filter
function; this function allows me to toggle the current Org-roam subject.
Function jnf/toggle-roam-subject-filter
implementation
(defun jnf/toggle-roam-subject-filter (subject)
"Prompt for a SUBJECT, then toggle the 's-i' kbd to filter for that subject."
(interactive (list
(completing-read
"Project: " (jnf/subject-list-for-completing-read))))
(global-set-key
;; Command + Control + i
(kbd "s-TAB")
(intern (concat "jnf/org-roam--" subject "--node-insert")))
(global-set-key
(kbd "C-s-c")
(intern (concat "jnf/org-roam--" subject "--capture")))
(global-set-key
(kbd "C-s-f")
(intern (concat "jnf/org-roam--" subject "--node-find")))
(global-set-key
(kbd "s-i")
(intern (concat "jnf/org-roam--" subject "--node-insert")))
(global-set-key
(kbd "C-c i")
(intern (concat "jnf/org-subject-menu--" subject "/body")))) (global-set-key
(kbd "C-c i")
(intern (concat "jnf/org-subject-menu--" project "/body"))))
The jnf/toggle-roam-subject-filter
function once had a hard-coded list of , but I extracted the jnf/subject-list-for-completing-read
function to leverage the jnf/org-roam-capture-subjects-plist
variable.
Function jnf/subject-list-for-completing-read
implementation
(cl-defun jnf/subject-list-for-completing-read (&key
(subjects-plist
jnf/org-roam-capture-subjects-plist))
"Create a list from the SUBJECTS-PLIST for completing read.
The form should be ‘(("all" 1) ("hesburgh-libraries" 2))."
;; Skipping the even entries as those are the “keys” for the plist,
;; the odds are the values.
(-non-nil (seq-map-indexed (lambda (subject index)
(when (oddp index) (list (plist-get subject :name) index)))
subjects-plist)))
Loading the Org Roam Package
With all of that pre-amble, I finally load the Org-roam package.
(use-package org-roam
:straight t
:custom
(org-roam-directory (file-truename "~/git/org"))
;; Set more spaces for tags; As much as I prefer the old format,
;; this is the new path forward.
(org-roam-node-display-template "${title:*} ${tags:40}")
(org-roam-capture-templates (jnf/org-roam-templates-for-subject :all))
:init
(add-to-list 'display-buffer-alist
'("\\*org-roam\\#"
(display-buffer-in-side-window)
(side . right)
(slot . 0)
(window-width . 0.33)
(window-parameters . ((no-other-window . t)
(no-delete-other-windows . t)))))
(setq org-roam-v2-ack t)
(org-roam-db-autosync-mode)
;; Configure the "all" subject key map
(jnf/toggle-roam-subject-filter "all"))
In loading the Org-roam package, I use the jnf/org-roam-templates-for-subject
function to ensure that the capture templates contain “all” of the expected templates.
I also use the jnf/toggle-roam-subject-filter
function to build the initial keymap for the “all” subject.
Conclusion
I hope it’s been helpful walking through the what and the how of implementing subject based contexts for Org-roam.
The process of refactoring towards the create-org-roam-subject-fns-for
macro helped me better think through the composition of the menus. In the early stages, I had 1 macro per function definition, but moved to the `(progn)
declaration to chain together the creation of several functions.
Top comments (0)