Diving into the Implementation of Subject Menus for Org Roam

It's Macros, Functions, and Property Lists…Oh My!

I wrote . 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/%<%Y%m%d>---${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/%<%Y%m%d>---${slug}.org"
          "#+title: ${title}\n#+FILETAGS: :personal:jeremy-friesen-consulting: %^G\n\n")
	 :unnarrowed t)
       :personal
       '("p" "Personal" plain "%?"
	 :if-new
         (file+head
          "personal/%<%Y%m%d>---${slug}.org"
	  "#+title: ${title}\n#+FILETAGS: :personal: %^G\n\n")
	 :unnarrowed t)
       :personal-encrypted
       '("P" "Personal (Encrypted)" plain "%?"
	 :if-new
         (file+head
          "personal/%<%Y%m%d>---${slug}.org.gpg"
          "#+title: ${title}\n#+FILETAGS: :personal:encrypted: %^G\n\n")
	 :unnarrowed t)
       :public
       '("u" "Public" plain "%?"
	 :if-new
         (file+head
          "public/%<%Y%m%d>---${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/%<%Y%m%d>---${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:

  1. Creates a function to find-file of the subject’s todo.
  2. Creates a subject specific capture function that wraps org-roam-capture.
  3. Creates a subject specific insert function that wraps org-roam-node-insert.
  4. Creates a subject specific find function that wraps org-roam-node-find.
  5. Uses pretty-hydra-define to create a subject specific menu.
  6. Uses pretty-hydra-define+ to append menu items to the jnf/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.