My org-roam workflows for taking notes and writing articles
This article documents the entirety of my org-mode based workflows, from note-taking to generating a website. I’ll mention all relevant packages, applications, and configuration.
The core of the workflows ¶
My workflows are based around org-roam, a personal knowledge management system built atop org-mode. At its core, org-roam implements an index of notes, using SQLite. This allows org-roam to provide fast auto-completion of notes, ID-based links, backlinks, and metadata-based search. It was written to implement Luhmann’s Zettelkasten method in Emacs, but it is flexible enough to be used for any kind of personal knowledge management.
I use org-roam for the majority of my Org files: articles, notes, facts, literature notes, inventories, etc. The only
exception is the inbox.org
file, which contains hastily taken notes and to-do items that will either be turned into
proper notes later, or will be deleted. Everything else is a note in org-roam.
The note taking system ¶
I’m not sticking to a strict system like Luhmann’s Zettelkasten. Notes may be short or long; they may contain original ideas, facts, or entire articles.
I use one file per note, primarily because I find it less overwhelming when a buffer is limited to a single note and don’t feel comfortable working with giant trees of notes.
I borrow some terms from Ahrens’s exposition of the Zettelkasten system [1]. Notes are
divided into three kinds: Fleeting notes, literature notes, and permanent notes. The definition of fleeting notes
matches Ahrens’s: they’re the kind of unstructured, everyday notes we are used to taking when a thought pops into our
head [1, Chap. 2.1]. Literature notes are notes you take while reading literature, or more
generally consuming content. Permanent notes are all other notes: original ideas, facts, journal entries, articles, etc.
This differs from Ahrens, where permanent notes are meant exclusively for your own ideas, and the Zettelkasten
explicitly shouldn’t be treated as a wiki or encyclopedia (
it would be rather misleading to think of his slip-box as a personal Wikipedia or a database on paper
[1, Chap. 1.3].) I do not differentiate between permanent notes and project notes.
Fleeting notes ¶
When I’m at my computer, I use org-capture
and the following capture template to add fleeting notes to
~/notes/inbox.org
:
("f" "Fleeting note" entry (file "~/notes/inbox.org") "* TODO %^{Note title}\nContext: %a\n%?" :empty-lines-before 1 )
The %a
verb expands to a link to where one was when they called org-capture
. This might, for example, be another
file or an email.
inbox.org
is an ordinary Org file and not part of org-roam’s database. Fleeting notes are short-lived and will be
deleted once they’ve been acted on. I might use them to write permanent notes, or they might be simple reminders of
things to do.
Literature notes and citations ¶
I (try to) take literature notes whenever I consume content that may contain helpful knowledge, be it papers, books, or videos.
The first step is to import the content into my reference management software of choice, Zotero. This guarantees that I have a record of all important metadata, a copy of the PDF/website/video, and a unique ID (the citation key) to refer to it.
In most cases, I use Zotero’s browser plugin, which automates most parts of the import, although some cleanups are often required, such as:
- Switching from title case to sentence case. Using sentence case is required to produce correct titles in all citation styles. The Better BibTeX plugin does this mostly automatically, though proper nouns need to be fixed manually afterward.
- Spliting author names into pairs of last name, first name – this can usually be done automatically, but that can get confused by prefixes like “von.”
- Changing the item type. Journal papers, conference papers, preprints, and theses aren’t always properly distinguished by the sites one imports the data from.
- For YouTube videos, I usually need to change the item type to Presentation, fix up titles, add presenters, etc.
For text documents, if Zotero hasn’t already downloaded a full PDF automatically, I use the Zotero SciHub plugin to fetch one. This usually works. Videos have to be downloaded manually using yt-dlp. For websites, the browser plugin automatically takes a snapshot that contains all of the styles and images necessary to render the page. Having a copy of everything ensures that the referenced content won’t change or disappear.
Once all the metadata is correct, I use the ZotFile plugin to rename all attachments to match the citation key. This
will later allow opening the file corresponding to a citation key. All of the files are stored in ~/notes/pdfs
. I also
use Better BibTeX to pin the citation key so that it doesn’t accidentally change in the future.
Zotero is configured to automatically export a BibLaTeX bibliography, which is placed in ~/notes/references.bib
. This
file is read by org-cite and citar to resolve citation keys.
Citar provides auto-completion for entries from the bibliography. I use it together with vertico and marginalia for my auto-completion setup. Additionally, citar provides functionality that integrates with org-mode and org-cite. I use the following customization options:
(org-cite-global-bibliography '("~/notes/references.bib"))
(org-cite-insert-processor 'citar)
(org-cite-follow-processor 'citar)
(org-cite-activate-processor 'citar)
(citar-bibliography '("~/notes/references.bib"))
(citar-notes-paths '("~/notes/references"))
(citar-symbols
`((file ,(all-the-icons-faicon "file-pdf-o" :face 'all-the-icons-green :v-adjust -0.1) . " ")
(note ,(all-the-icons-material "speaker_notes" :face 'all-the-icons-blue :v-adjust -0.3) . " ")
(link ,(all-the-icons-octicon "link" :face 'all-the-icons-orange :v-adjust 0.01) . " ")))
(citar-symbol-separator " ")
This configures org-cite to rely on citar for inserting, viewing, and following citations. It also makes citar’s auto-completion prettier, using fancy icons.
org-cite is a minimal frontend for handling citations in Org files and is included with org-mode. It introduces cite:
links, which are citations. By using the #+print_bibliography:
instruction, a bibliography will be included when
exporting from Org to other formats. Citations will also be formatted according to the configured citation style. Citar
builds on org-cite and expands on the possible actions on citations, such as jumping to the corresponding literature
notes in org-roam or opening the referenced content.
To actually take literature notes, I use the citar-open
function and select the relevant bibliography entry. This will
present me with multiple options, one of which is to open or create a note. Because I am using citar-org-roam and have
enabled the citar-org-roam-mode
minor mode, this will create a new org-roam note (instead of an ordinary Org file).
The new note will have the ROAM_REFS
property populated with the citation key, allowing org-roam to connect cite:
links with the literature notes, tracking backlinks as it does for other notes. See org-roam’s manual for more
information about ROAM_REFS
.
To revisit these notes later, I can use citar-open
again or follow cite:
links in other notes that reference the
content. Both of these also allow me to open the file (such as a PDF) corresponding to a key. In theory, I should
configure citar-library-paths
which lets citar look up files from citation keys. However, the exported BibTeX file
contains absolute paths to these files, which citar apparently can use.
Literature notes go in the ~/notes/references
subdirectory. This behavior is controlled by the citar-org-roam-subdir
variable, which defaults to "references"
.
I want to include BibTeX entries with my literature notes so that notes published on my website are self-contained.
Unfortunately, citar-org-roam doesn’t allow customizing the capture template being used. Instead, I redefine
citar-org-roam--create-capture-note
. I also copy citar--insert-bibtex
and modify it to return a string instead of
inserting into a buffer.
;; citar-org-roam only offers the citar-org-roam-note-title-template variable
;; for customizing the contents of a new note and no way to specify a custom
;; capture template. And the title template uses citar's own format, which means
;; we can't run arbitrary functions in it.
;;
;; Left with no other options, we override the
;; citar-org-roam--create-capture-note function and use our own template in it.
(defun dh/citar-org-roam--create-capture-note (citekey entry)
"Open or create org-roam node for CITEKEY and ENTRY."
;; adapted from https://jethrokuan.github.io/org-roam-guide/#orgc48eb0d
(let ((title (citar-format--entry
citar-org-roam-note-title-template entry)))
(org-roam-capture-
:templates
'(("r" "reference" plain "%?" :if-new
(file+head
"%(concat
(when citar-org-roam-subdir (concat citar-org-roam-subdir \"/\")) \"${citekey}.org\")"
"#+title: ${title}\n\n#+begin_src bibtex\n%(dh/citar-get-bibtex citekey)\n#+end_src\n")
:immediate-finish t
:unnarrowed t))
:info (list :citekey citekey)
:node (org-roam-node-create :title title)
:props '(:finalize find-file))
(org-roam-ref-add (concat "@" citekey))))
;; citar has a function for inserting bibtex entries into a buffer, but none for
;; returning a string. We could insert into a temporary buffer, but that seems
;; silly. Plus, we'd have to deal with trailing newlines that the function
;; inserts. Instead, we do a little copying and implement our own function.
(defun dh/citar-get-bibtex (citekey)
(let* ((bibtex-files
(citar--bibliography-files))
(entry
(with-temp-buffer
(bibtex-set-dialect)
(dolist (bib-file bibtex-files)
(insert-file-contents bib-file))
(bibtex-search-entry citekey)
(let ((beg (bibtex-beginning-of-entry))
(end (bibtex-end-of-entry)))
(buffer-substring-no-properties beg end)))))
entry))
(advice-add #'citar-org-roam--create-capture-note :override #'dh/citar-org-roam--create-capture-note)
Permanent notes ¶
In my system, permanent notes are the opposite of fleeting notes. They are written more deliberately, in a way that’s understandable without further context. They will usually not be deleted, although I may sometimes merge multiple notes into a bigger note. Permanent notes can contain original ideas, observations, facts, or any other piece of information.
Working with permanent notes follows basic org-mode and org-roam workflows, namely:
- Using
org-roam-node-find
to edit or create notes. - Using
org-roam-node-insert
to add links to other notes. - Using
citar-insert-citation
to cite sources. I could useorg-cite-insert
instead, but the citar variant has the benefit of also working in Markdown and LaTeX buffers, which is why I’ve made it a habit to always usecitar-insert-citation
.
For grouping notes by category, I use a :context:
property that links to notes representing the categories. The
backlinks of a category note act as an automatic index of notes in that category. Backlinks can be viewed in the
org-roam buffer, which can be displayed with org-roam-buffer-toggle
. The benefit of using notes for categories,
instead of tags, is that I can trivially describe a category simply by filling out its note.
The :previous:
property denotes that a note is part of a series by linking to the previous note. The links allow
walking the series backwards, and automatic backlinks allow walking it forwards.
Articles ¶
Articles are permanent notes that are published on my blog. They have the following additional metadata:
#+filetags
to specify tags for the article, which will be exported to Hugo. These tags are different from category notes. Tags (more specifically, taxonomies) are a built-in feature of Hugo, whereas Hugo has no idea what to do with category notes.#+hugo_publishdate
to specify the publication date.:kind: article
in the property drawer to differentiate articles from notes. This is used to select the right subset of notes for publication.
There are no fundamental differences between “normal” permanent notes and articles. Articles are simply notes I deem interesting enough to be listed on the website’s front page. Of course, articles get more thought put into them and will be edited to a higher standard than random notes.
Website generation ¶
As the articles on my website are org-roam notes, I need a way to turn them into something that can be uploaded and rendered in a browser. I prefer the static site approach for static content and am already using Hugo for other projects. The problem, then, is to convert Org files to Hugo-compatible Markdown, generating valid front matter and converting links between notes to links that will work online. ox-hugo does all of that.
All that is needed is some glue that filters out all articles and exports them:
(defun dh/org-cite-export-bibliography-advice (fn keyword _ info)
(if (org-cite-list-keys info)
(funcall fn keyword nil info)))
;; The CSL style we use causes an error when trying to export an empty bibliography. Wrap the relevant function to
;; prevent that from happening.
(advice-add #'org-cite-export-bibliography :around #'dh/org-cite-export-bibliography-advice)
(defun dh/org-roam-node-directory (node)
(string-remove-suffix
"/"
(string-remove-prefix
"/"
(string-remove-prefix
org-roam-directory
(file-name-directory (org-roam-node-file node))))))
(defun dh/org-roam-articles ()
(cl-remove-if-not
(lambda (node)
(string= "article" (cdr (assoc-string "KIND" (org-roam-node-properties node)))))
(org-roam-node-list)))
(defun dh/org-roam-to-hugo (section files)
"Call `org-hugo-export-to-md' on all Org FILES.
All files have to be in `org-roam-directory'. Output is written
relative to SECTION in `org-hugo-base-dir'. Org files in
subdirectories of `org-roam-directory' will get matching
subdirectories underneath SECTION."
(mapcar
(lambda (node)
(with-current-buffer (find-file-noselect (org-roam-node-file node))
(let ((org-hugo-section (file-name-concat section (dh/org-roam-node-directory node))))
(org-hugo-export-to-md))))
files))
(defun dh/org-insert-date-keyword ()
(org-roam-set-keyword "date" (format-time-string "[%Y-%m-%d %a]" (current-time))))
(defun dh/org-export-before-parsing (backend)
(when (string= backend "hugo")
(org-roam-set-keyword
"hugo_lastmod"
(format-time-string "%Y-%m-%d" (file-attribute-modification-time (file-attributes (buffer-file-name)))))))
(dh/org-roam-to-hugo "articles" (dh/org-roam-articles))
Additionally, some properties are set in ~/notes/.dir-locals.el
:
((org-mode . ((org-hugo-base-dir . "~/prj/honnef.co")
(org-cite-export-processors . ((t csl "~/notes/ieee.csl")))
(eval . (setq-local
org-export-before-parsing-functions
(append org-export-before-parsing-functions '(dh/org-export-before-parsing)))))))
org-cite-export-processors
configures how in-text citations and bibliographies should be rendered. I use the CSL
processor and the IEEE style. To be able to use CSL, the citeproc package needs to be installed.
The function that’s added to org-export-before-parsing-functions
ensures that the Hugo front matter includes the
lastmod
property, set to the Org file’s modification time. This way, lastmod updates whenever the file gets saved, or
when it gets edited with external tools.
The same approach could be used to set other Hugo-specific front matter without having to “pollute” the Org files.
For ox-hugo’s output to render correctly in Hugo, “unsafe” markup has to be permitted. Otherwise, its Markdown parser will reject HTML inside Markdown files:
[markup.goldmark.renderer]
unsafe = true
Because I am already generating my website this way, I might as well publish non-article notes, as a sort of braindump.
Most notes will not be useful to a large audience, but Google will be able to index them nevertheless, and if only one
person finds a note helpful, that’ll be enough to make it worth it. However, some of my notes are private and should not
be published. These notes have a :private:
attribute in their property drawer and get filtered out:
(defun dh/org-roam-public-notes ()
(cl-remove-if
(lambda (node)
(or
(cdr (assoc-string "PRIVATE" (org-roam-node-properties node)))
(string= "article" (cdr (assoc-string "KIND" (org-roam-node-properties node))))))
(org-roam-node-list)))
(dh/org-roam-to-hugo "notes" (dh/org-roam-public-notes))
Public notes may contain links to private notes, which would result in links Hugo can’t resolve. By advising
org-hugo-link
, one can filter out such links. Going a step further, if the private note has a ROAM_REFS
property
that is a URL, the link to the note can be replaced with a link to the URL. This might happen, for example, if there are
private notes about websites. This idea was based on code from [2].
(defun dh/org-link-advice (fn link desc &rest rest)
(if (string= "id" (org-element-property :type link))
(dh/org-link-by-id fn link desc rest)
(apply fn link desc rest)))
(defun dh/org-link-by-id (fn link desc rest)
(let ((node (org-roam-node-from-id (org-element-property :path link)))
(protocols '("http://" "https://" "ftp://")))
(if (assoc-string "PRIVATE" (org-roam-node-properties node))
;; The linked-to note is private. If it has a ROAM_REFS property with a URL in it, link to that URL, otherwise
;; only insert the link description, but no target.
(if-let ((url (seq-find (lambda (arg) (cl-some (lambda (p) (string-prefix-p p arg)) protocols))
(split-string-and-unquote (or (cdr (assoc-string "ROAM_REFS" (org-roam-node-properties node))) "")))))
(format "[%s](%s)" desc url)
desc)
;; Note isn't private, use original FN.
(apply fn link desc rest))))
(advice-add #'org-hugo-link :around #'dh/org-link-advice)
On the Hugo side, I use the following partial for rendering backlinks to notes, which was adapted from [3].
{{ $re := $.File.BaseFileName }}
{{ $backlinks := slice }}
{{ range .Site.AllPages -}}
{{ if and (findRE $re .RawContent) (not (eq $re .File.BaseFileName)) }}
{{ $backlinks = $backlinks | append . }}
{{ end -}}
{{ end }}
{{ if gt (len $backlinks) 0 }}
<h2 id="backlinks"><a class="heading-anchor" href="#backlinks">##</a> Links to this note</h2>
<ul>
{{ range $backlinks }}
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{ end }}
There are several other options for generating websites from org-roam (and org-mode in general) files. I’ve arrived at this setup after reading several articles by other org-roam users [4], [5], [6], [7].
Common elements ¶
I want all of my notes to have a #+date
property, set to the creation time of the note. Instead of having to modify
all templates to include it, I add a hook to org-roam-capture-new-node-hook
that inserts the property whenever a new
note is created.
(defun dh/org-insert-date-keyword ()
(org-roam-set-keyword "date" (format-time-string "[%Y-%m-%d %a]" (current-time))))
(add-hook 'org-roam-capture-new-node-hook #'dh/org-insert-date-keyword)
I use org-roam-ui to get an overview of my notes, spot clusters of links, and quickly navigate related notes. You could consider this the digital equivalent of browsing the notes in your slip box.
To make it easier to capture notes and work with literature, I define my own transient menu that is bound to the F1 key:
(transient-define-prefix dh-do-stuff ()
""
["Org"
[("oc" "org-capture" org-capture)
("ol" "org-store-link" org-store-link)
("ornf" "org-roam-node-find" org-roam-node-find)
("orni" "org-roam-node-insert" org-roam-node-insert)
("ci" "Insert citation" citar-insert-citation)
("co" "citar-open" citar-open)]])
(global-set-key (kbd "<f1>") #'dh-do-stuff)
Summary ¶
I use the following Emacs packages:
- citar
- citar-org-roam
- citeproc
- marginalia
- org-cite
- org-mode
- org-roam
- org-roam-ui
- ox-hugo
- transient
- vertico
I use the following external software: