Org-roam tags
Functions for managing tags in org-roam notes with completion support. Whilst this functionality is now part of org-roam v2, the code demonstrates how to extend org-roam and work with its database.
Org-roam is a note-taking tool built on top of Emacs and Org. Essentially, it's a replica of Roam Research. These tools provide an easy way to create and manage non-hierarchical notes. If you want to learn more, take a look at the Org-roam manual or watch Making Connections in your Notes by Matt Williams. Believe me, Org-roam and Roam Research are game-changers. Or better yet, don't believe me and validate my claim yourself.
Since I'm already addicted, it was natural to prefer org-roam over some web application. Beyond being built on top of the mature Org ecosystem, Emacs brings many benefits - extensibility being one of them. Once org-roam introduced a tags system in v1.1.1, I felt the lack of functions to manage them. Adding and removing tags by hand isn't pleasant, so in this article I'm sharing a snippet I created to make tag management easier.
Change Log:
[2020-10-12 Mon]: Functionality described in this post (and similar functionality to manage aliases) is merged to the upstream. Now simply use one of the following functions:org-roam-tag-addorg-roam-tag-deleteorg-roam-alias-addorg-roam-alias-delete
[2021-07-31 Sat]: With release of org-roam v2 you should use the following functions:org-roam-tag-addorg-roam-tag-removeorg-roam-alias-addorg-roam-alias-remove
When it comes to tag removal, I just want to have a list of tags set in the current buffer and choose one of them to remove. The important thing here is that it shouldn't allow me to remove tags set by directories (see org-roam-tag-sources).
On the other hand, when I add a tag, I want to see the list of all tags set either by buffer property or by directory. I can choose one of them (otherwise I tend to mistype) or add a completely new one.
So let's implement these two functions. First, we need a function to get the list of buffer-wide tags. For this, we can write a simple helper (that uses regexps) or reuse the internal API from org-roam (which does all the dirty work for us).
(defun +org-notes-tags-read () "Return list of tags as set in the buffer." (org-roam--extract-tags-prop (buffer-file-name (buffer-base-buffer))))
Now it's easy to implement the function to delete one of the buffer tags.
(defun +org-notes-tags-delete () "Delete a tag from current note." (interactive) (unless (+org-notes-buffer-p) (user-error "Current buffer is not a note")) (let* ((tags (+org-notes-tags-read)) (tag (completing-read "Tag: " tags nil 'require-match))) (+org-buffer-prop-set "ROAM_TAGS" (combine-and-quote-strings (delete tag tags))) (org-roam-db--update-tags)))
Since it only works in the context of org-roam, it's good to have a meaningful error when this function is used in an invalid context. Next, we read the buffer tags and select one of them using completing-read. The 'require-match is just a dummy non-nil value (used instead of t) that improves readability.
Next, we remove the selected tag from the tags list and set the result to the buffer property ROAM_TAGS, with each tag quoted. Simple as that. I'll provide the implementation of +org-notes-buffer-p and +org-buffer-prop-set later on.
And the most important function is for adding tags.
(defun +org-notes-tags-add () "Add a tag to current note." (interactive) (unless (+org-notes-buffer-p) (user-error "Current buffer is not a note")) (let* ((tags (seq-uniq (+seq-flatten (+seq-flatten (org-roam-db-query [:select tags :from tags]))))) (tag (completing-read "Tag: " tags))) (when (string-empty-p tag) (user-error "Tag can't be empty")) (+org-buffer-prop-set "ROAM_TAGS" (combine-and-quote-strings (seq-uniq (cons tag (+org-notes-tags-read))))) (org-roam-db--update-tags)))
It also errors out when called outside an org-roam buffer. Then we query all tags from org-roam-db. Since this is a list of lists of lists, we have to double-flatten the result and then keep only unique entries. After that, everything is straightforward.
Now for the missing functions:
(defun +org-notes-buffer-p () "Return non-nil if the currently visited buffer is a note." (and buffer-file-name (string-equal (file-name-as-directory org-roam-directory) (file-name-directory buffer-file-name)))) (defun +seq-flatten (list-of-lists) "Flatten LIST-OF-LISTS." (apply #'append list-of-lists)) (defun +org-buffer-prop-set (name value) "Set a buffer property called NAME to VALUE." (save-excursion (widen) (goto-char (point-min)) (if (re-search-forward (concat "^#\\+" name ": \\(.*\\)") (point-max) t) (replace-match (concat "#+" name ": " value)) ;; find the first line that doesn't begin with ':' or '#' (let ((found)) (while (not (or found (eobp))) (beginning-of-line) (if (or (looking-at "^#") (looking-at "^:")) (line-move 1 t) (setq found t))) (insert "#+" name ": " value "\n")))))
That's it! You can find the complete solution as a gist on GitHub. Have fun!