Leveling up my org-roam system
I've used org-roam for a while now for my personal Zettelkasten. This started with me leaving Evernote for Notion. Notion was a good tool, but it's still not a tool that I control/manage. If Notion goes out of business or changes its model in 10 years, I don't want to have to redo my system again.
So, I've been using org-roam.
emacs setup & functionality
My settings.org setup is like this:
; needed for =org-roam-deadgrep=
(use-package deadgrep)
; These are outside use-package so they can be overridden
(setopt org-roam-directory (expand-file-name "FIXME-PATH-TO-ORG-ROAM"))
(setopt org-roam-capture-templates
'(("d" "default" plain "%?" :target
(file+head "%<%Y%m%d%H%M%S>-${slug}.org" "#+title: ${title}
- tags :: ")
:unnarrowed t)))
(use-package org-roam
:after org
:commands org-roam-db-query
:custom
(org-roam-mode-sections
(list #'org-roam-backlinks-section
#'org-roam-reflinks-section
;; #'org-roam-unlinked-references-section
))
(org-roam-node-display-template
(concat "${title:*} "
(propertize "${tags:10}" 'face 'org-tag)))
:config
(org-roam-setup)
(defun org-roam-node-insert-immediate (arg &rest args)
(interactive "P")
(let ((args (cons arg args))
(org-roam-capture-templates (list (append (car org-roam-capture-templates)
'(:immediate-finish t)))))
(apply #'org-roam-node-insert args)))
(defun org-roam-deadgrep (arg)
(interactive "MSearch term: ")
(deadgrep arg org-roam-directory))
; see https://gist.github.com/borwickatuw/098b0d3a3b599f3f00c069309e8fd462
(require 'org-roam-copy)
; https://github.com/org-roam/org-roam/issues/991#issuecomment-882010053
(add-to-list 'magit-section-initial-visibility-alist (cons 'org-roam-node-section 'hide))
:bind (("C-c n f" . org-roam-node-find)
("C-c n r" . org-roam-node-random)
("C-c n c" . org-roam-capture)
("C-c n g" . org-roam-graph)
("C-c n s" . org-roam-deadgrep)
("C-c n y" . org-roam-copy-node)
(:map org-mode-map
(
("C-c n i" . org-roam-node-insert)
("C-c n I" . org-roam-node-insert-immediate)
("C-c n o" . org-id-get-create)
("C-c n t" . org-roam-tag-add)
("C-c n a" . org-roam-alias-add)
("C-c n l" . org-roam-buffer-toggle)
))))
In practice, here's what all that does:
C-c nis theorg-roamprefixC-c n ffinds a node- When in org-roam,
C-c n iinserts a reference - Searching will show the tags for the node
- John B. addition:
C-c n sdoes a "deadgrep" search through org-roam content - John B. addition:
C-c n ywill callorg-roam-copy. This will find the node and then go to the:COPY:property and copy its content. For example, if you have a nodeMy work phone numberand you put your number in the:COPY:property then when you useC-c n yand find the node, your clipboard will get the phone number. Basically this is a quick copy&paste for non-sensitive content
Adding more data into org-roam
Recently, I've been adding more data into org-roam:
contacts
This one is actually a few years old. I use vdirsyncer to sync my Google contacts to my local computer. I then have a one-way sync from vdirsyncer into org-roam. This lets me pull contact details into org-roam. I can then annotate the entries, e.g. to make a note when I last called a vendor.
emails
This one is way more experimental; I had Claude Code create some scripts that in turn called ollama to parse some of my old emails and identify other contacts I needed to add.
mnemosyne
I have a life archive where I keep copies of stuff. I created a job that will generate an org-roam file mnemosyne for this archive, including the top- and second-level directory structures. Each top-level directory gets its own org-roam ID/node.
notion
This is a one-time import from notion. It is pretty messy because I wanted to select exactly what content to migrate. For example, I wanted some of the Notion databases to come across but as a single org-roam page.
pinboard
This is an ongoing sync job that creates a "pinboard" org-roam entry. That entry has all my pinboard bookmarks in it along with their tags. This content is marked read-only in org-roam. Each item is also an org-roam node. This way I can reference content without needing to pull a whole copy of the content into org-roam. This also reinforces that pinboard is the "correct" location for external bookmarks.
p.s. this is also insurance for if pinboard.in goes away one day.
wiki
This makes a read-only copy of all the org files that generate https://johnborwick.com/wiki/. I want to be able to reference these in org-roam.
youtube
I list playlists in a config file and then this generates a read-only org-roam file for each playlist. This also has code to look up private video titles (for videos that have been made private since I added them to a playlist, such as a version of David Bowie's "Life on Mars").
org-roam auditing
Turn out that org-roam content can really go stale/bad, especially when you're dumping a ton of content into it! I have several jobs to help with this:
audit.py: Fixes structural issues such as sub-headings without parent headingsbroken_refs.py: Finds references to files or org-roam IDs where the target doesn't exist. Also looks for stray[[charactersorphan_media.py: Finds media that isn't linked anywhereweb_content.py: Finds org-roam content that's really copied from a web site. (My Notion import, especially, had a lot of copied HTML content from web sites.) I can then interactively remove this content & add it to pinboard.
Using org-roam on my phone
So, org-roam doesn't work great on the phone. There are org-mode readers but they don't respect the org-roam id: links. So, last year I built a basic exporter. It collates the org-roam files into one gigantic org file, converts the ID links into anchors, and then runs pandoc on them. I then put the file in Dropbox.
Unfortunately, I now have an iPhone and the iPhone doesn't want Safari to open a local file. So, I have recently switched to generating an .epub file. I can then open this on the phone.
Additionally, I added a git post-commit hook to my org-roam directory, so that this epub file gets regenerated. Here's the hook:
#!/bin/bash
set -e
PLIST=~/Library/LaunchAgents/com.johnborwick.org-roam-export.plist
PLIST_SOURCE=FIXME-PATH-TO/com.johnborwick.org-roam-export.plist
LABEL=com.johnborwick.org-roam-export
if ! launchctl list "$LABEL" >/dev/null 2>&1; then
if [ ! -f "$PLIST" ]; then
test -f "$PLIST_SOURCE" || { echo "ERROR: $PLIST_SOURCE not found" >&2; exit 1; }
cp "$PLIST_SOURCE" "$PLIST"
fi
launchctl load "$PLIST"
fi
touch ~/.local/share/org-roam-export-trigger
This bootstraps a launchctl job that watches that org-roam-export-trigger file. When that file changes, it kicks off the job. This is necessary because I have a hook in Emacs to commit to git when I save an org-roam file. Emacs will wait on the hook to finish running, even if you use nohup and stuff.
Here's the plist file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.johnborwick.org-roam-export</string>
<key>ProgramArguments</key>
<array>
<string>FIXME-PATH-TO/run_org_roam_export.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/john/.local/share/org-roam-export-trigger</string>
</array>
<key>StandardOutPath</key>
<string>/tmp/org-roam-export.log</string>
<key>StandardErrorPath</key>
<string>/tmp/org-roam-export.log</string>
</dict>
</plist>
In turn, the run_org_roam_export.sh script runs my org_roam_export.hy script.
Leveled up!
With all of this, I feel like I really have a clear, connected system now:
- Readwise Reader is for reading non-books (I have separate jobs that will synchronize Readwise Reader with pinboard unread bookmarks)
- A book tool (currently Hardcover) tracks the books I want to read
- org-roam has all my reference content
- I can link to org-roam from elsewhere in Emacs
- I can access the org-roam content via iOS's Books app (sort of)