I found org-roam too slow, so I made quickroam. And that idea spun off into this package, a standalone thing. I hope it’s also easier to learn.
- If you were using org-roam, there is nothing to migrate. You can use both packages. It’s exactly the same on-disk format: “notes” are identified by their org-id.
With optional shims, you can even skip syncing the org-roam DB and continue using its rich backlinks buffer and org-roam-capture!
In pursuit of being “just org-id”, this package has no equivalent setting to
org-roam-directory
– it just looks uporg-id-locations
. - If you were not using org-roam, maybe think of it as a limited, focused org-ql. If you were the sort of person to prefer ID-links over file links or any other type of link, you’re in the right place! Now you can rely on IDs, and forget about filenames and hierarchies and directory structures – as long as you’ve assigned an ID to something, you can find it later.
And you can still keep doing hierarchies, where that brings you joy, unlike systems that mandate limitations like “one note per file”.
My life can be divided into two periods ”before org-roam” and ”after org-roam”. I crossed a kind of gap once I got a good way to link between my notes. It’s odd to remember when I just relied on browsing hierarchies of subtrees – what a strange way to do things!
I used to lose track of things I had written, under some forgotten heading in a forgotten file in a forgotten directory. The org-roam method let me find and build on my own work, instead of having repetitive cryptomnesia and staying on square one forever.
At the core, all the “notetaking packages” (orgrr/zk/zetteldeft/org-roam/denote/howm/minaduki/…) try to help you with this: make it easy to link between notes and explore them.
Right off the bat, that imposes two requirements: a method to search for notes, since you can’t link to something you can’t search for, and a design-philosophy about exactly what kinds of things should turn up as search hits. What’s a “note”?
Just searching for Org files is too coarse, and just searching for any subtree anywhere brings in too much clutter.
Here’s what org-roam invented. It turns out that if you limit the search-hits to just those files and subtrees you’ve deigned to assign an org-id – which roughly maps to everything you’ve ever thought it was worth linking to – it filters out the noise excellently.
Once a subtree has an ID you can link to, it’s a “node” because it has joined the wider graph, the network of linked nodes. I wish the English language had more distinct sounds for the words “node” and “note”, but to clarify, I’ll say “ID-node” when the distinction matters.
A comparison of three similar systems, all permitting org-id as first-class citizen and not locking you into an “one-note-per-file” concept.
org-roam | org-node | org-super-links | |
---|---|---|---|
Backlinks | yes | yes | yes |
Node search and insert | yes | yes | – (suggests org-ql) |
Node aliases | yes | yes | not applicable |
Rich backlinks buffer | yes | yes (org-roam’s) | – |
Reflinks | yes | yes (as backlinks) | – |
Ref search | yes | yes (as aliases) | – |
Extract subtree to new file | yes | yes | – |
Can configure rich completions | yes | yes | not applicable |
org-roam-capture integration | yes | yes | – |
org-capture integration | – | yes | – |
Backlinks in same window | – | yes | yes |
Avoid double-counting :BACKLINKS: | – | yes | yes |
Node exclusion | yes | limited | not applicable |
Support roam: links | yes | – | – |
Support roam citations (#6) | yes | – | – |
Some query-able cache | org-roam-db | org-nodes | – |
Asynchronous caching | – | yes | yes, very async ;) |
Time to re-cache my 2000 files | 2m 48s | 0m 02s | not applicable |
Add an init snippet like this (assuming straight.el):
(use-package org-node
:straight (org-node :type git :host github :repo "meedstrom/org-node")
:hook (org-mode . org-node-cache-mode))
If you’re new to these concepts, fear not. The main things for day-to-day operation are two verbs: “find” and “insert-link”.
Pick some good keys and try them out, and you can come back to this README later—or never.
(global-set-key (kbd "<f2> f") #'org-node-find)
(global-set-key (kbd "<f2> i") #'org-node-insert-link)
(If you don’t like F2, maybe M-s?)
(global-set-key (kbd "M-s f") #'org-node-find)
(global-set-key (kbd "M-s i") #'org-node-insert-link)
To browse config options, type M-x customize-group RET org-node RET
.
Final tip for the newbie: there’s no separate command for creating a new node! Reuse one of the commands above, and type the name of a node that doesn’t exist.
These user options help you feel at home using both packages side-by-side:
(setq org-node-creation-fn #'org-node-new-by-roam-capture)
(setq org-node-slug-fn #'org-node-slugify-like-roam)
Also, either run M-x org-roam-update-org-id-locations
, or edit the following option so it includes your org-roam-directory
. Ideally org-id would manage itself, but it doesn’t do it super-well, and this is insurance.
(setq org-node-extra-id-dirs '("~/org/")) ;; ... assuming that's your org-roam-directory
If you’ve been struggling with slow saving of big files in the past, consider these org-roam settings:
(setq org-roam-db-update-on-save nil) ;; don't update DB on save, not needed
(setq org-roam-link-auto-replace nil) ;; don't look for "roam:" links on save
With that done, try out the commands already mentioned in Quick start. There’s more under Toolbox. Enjoy!
If you also want the org-roam-buffer, see the next section.
Want to keep using M-x org-roam-buffer-toggle
?
If you didn’t have laggy saves, this is fine. In other words, keep org-roam-db-update-on-save
at t.
The following hook should keep the database synced.
(add-hook 'org-node-cache-rescan-file-hook #'org-node-feed-file-to-roam-db)
For a full reset, equivalent to C-u M-x org-roam-db-sync
, you can type M-x org-node-feed-roam-db
. It’s still slow, but interestingly, all the slowness comes from EmacSQL or SQLite. If someone figures out how to optimize that, please let me know!
Yes, it’s possible!
(advice-add 'org-roam-backlinks-get :override
#'org-node--fabricate-roam-backlinks)
(advice-add 'org-roam-reflinks-get :override
#'org-node--fabricate-roam-reflinks)
I like these solutions because I rarely have the screen space to display a backlink buffer.
For a first-time run, type M-x org-node-backlink-fix-all
. (Don’t worry, if you change your mind, you can undo with M-x org-node-backlink-regret
.)
Then start using the minor mode org-node-backlink-mode
, which keeps these properties updated. Init snippet:
(add-hook 'org-mode-hook #'org-node-backlink-mode)
Option 2B. Let org-super-links manage a :BACKLINKS:...:END:
drawer.
I think the following should work. Totally untested, let me know!
(add-hook 'org-node-insert-link-hook #'org-node-convert-link-to-super)
Alas, this is mainly directed towards people were using org-super-links from the beginning, as there is not yet a bulk command to add drawers to all nodes. (Issue 93)
You may have heard that org-roam has its own set of capture templates: the org-roam-capture-templates
.
It can make sense, for people who fully understand the magic of capture templates. I didn’t, so I was not confident using a second-order abstraction over an already leaky abstraction.
So can we reproduce the functionality on top of vanilla org-capture? That’d be less scary. The answer is yes!
Here are some example capture templates. The secret sauce is (function org-node-capture-target)
.
(setq org-capture-templates
'(("n" "ID node")
("nc" "Capture to ID node (maybe creating it)"
plain (function org-node-capture-target) nil
:empty-lines-after 1)
("nv" "Visit ID node (maybe creating it)"
plain (function org-node-capture-target) nil
:jump-to-captured t
:immediate-finish t)
;; Sometimes useful with `org-node-insert-link' to make a stub you'll
;; fill in later
("ni" "Instantly create stub ID node without visiting"
plain (function org-node-capture-target) nil
:immediate-finish t)))
And if you want the commands org-node-find
& org-node-insert-link
to likewise outsource to org-capture when creating new nodes:
(setq org-node-creation-fn #'org-capture)
I find unsatisfactory the config options in org-id (Why? See Taking ownership of org-id), so org-node gives you an additional way to feed data to org-id, making sure we won’t run into “ID not found” situations.
Example setting:
(setq org-node-extra-id-dirs
'("/home/kept/notes"
"/home/kept/project1/"
"/home/kept/project2/")
How to see the headings’ full outline paths while searching for nodes:
;; Prepend completions with the heading's outline path
(setq org-node-format-candidate-fn
(lambda (node title)
(if-let ((olp (org-node-get-olp node)))
(concat (string-join olp " > ") " > " title)
title)))
(When tinkering with this expression, test the result by evalling the form and doing a M-x org-node-reset
.)
A variant I like, that greys out the ancestor headings and includes the file title:
(setq org-node-format-candidate-fn
(lambda (node title)
(if (org-node-get-is-subtree node)
(let ((ancestors (cons (org-node-get-file-title-or-basename node)
(org-node-get-olp node)))
(result nil))
(dolist (anc ancestors)
(push (propertize anc 'face 'shadow) result)
(push " > " result))
(push title result)
(string-join (nreverse result)))
title)))
The option org-node-filter-fn
works well for excluding TODO items that happen to have an ID, and excluding org-drill items and that sort of thing, but beyond that, it has limited utility because unlike org-roam, child ID nodes of an excluded node are not excluded!
So let’s say you have a big archive file, fulla IDs, and you want to exclude all of them from appearing as search hits. Putting a :ROAM_EXCLUDE: t
at the top won’t do it. As it stands, what I’d suggest is unfortunately, look at the file name.
While the point of org-id is to avoid dependence on filenames, it’s often pragmatic to let up on purism just a bit :-) It works well for me to filter out any file or directory that happens to contain “archive” in the name, via the last line here:
(setq org-node-filter-fn
(lambda (node)
(not (or (org-node-get-todo node) ;; Ignore headings with todo state
(member "drill" (org-node-get-tags node)) ;; Ignore :drill:
(assoc "ROAM_EXCLUDE" (org-node-get-properties node))
(string-search "archive" (org-node-get-file-path node))))))
Commands:
org-node-find
org-node-insert-link
org-node-insert-transclusion
org-node-insert-transclusion-as-subtree
org-node-rename-file-by-title
- Auto-rename the file based on the current
#+title
- Auto-rename the file based on the current
org-node-rewrite-links-ask
- Look for link descriptions that got out of sync with the current node title, then prompt at each link to update it
org-node-rename-asset-and-rewrite-links
- Interactively rename an asset such as an image file and try to update all Org links to them. Requires wgrep.
- NOTE: For now, it only looks for links inside the root directory that it prompts you for, and sub and sub-subdirectories and so on – but won’t find a link in a completely different place. Like if you have Org files under /media linking to assets in /home, those links won’t be updated.
- Interactively rename an asset such as an image file and try to update all Org links to them. Requires wgrep.
org-node-extract-subtree
- A bizarro counterpart to
org-roam-extract-subtree
. Export a subtree at point into a file-level node, leave a link where it was, and show the new file as the current buffer.
- A bizarro counterpart to
org-node-random
- Visit a random node
org-node-nodeify-entry
- (Trivial) Give an ID to the subtree at point (and run org-node-creation-hook)
org-node-insert-heading
- (Trivial) Insert a new heading with an ID (and run org-node-creation-hook)
org-node-backlink-fix-all
- Add
BACKLINKS
property to all nodes everywhere (takes a while)
- Add
org-node-backlink-regret
- In case you regret the
BACKLINKS
properties – remove them all
- In case you regret the
API comparison between org-roam and org-node.
Action | org-roam | org-node |
---|---|---|
Get ID at point | (org-roam-id-at-point) | (org-entry-get nil "ID" t) |
Get node at point | (org-roam-node-at-point) | (org-node-at-point) |
Get list of files | (org-roam-list-files) | (org-node-files) |
Prompt user to pick a node | (org-roam-node-read) | (org-node-read) |
Get backlink objects | (org-roam-backlinks-get NODE) | (gethash (org-node-get-id NODE) org-node--links-table) |
Get reflink objects | (org-roam-reflinks-get NODE) | (gethash (org-node-get-id NODE) org-node--reflinks-table) |
Get title | (org-roam-node-title NODE) | (org-node-get-title NODE) |
Get title of file where NODE is | (org-roam-node-file-title NODE) | (org-node-get-file-title NODE) |
Get title or name of file where NODE is | (org-node-get-file-title-or-basename NODE) | |
Get ID | (org-roam-node-id NODE) | (org-node-get-id NODE) |
Get filename | (org-roam-node-file NODE) | (org-node-get-file-path NODE) |
Get tags | (org-roam-node-tags NODE) | (org-node-get-tags NODE) , no inherited tags |
Get outline level | (org-roam-node-level NODE) | (org-node-get-level NODE) |
Get char position | (org-roam-node-point NODE) | (org-node-get-pos NODE) |
Get properties | (org-roam-node-properties NODE) | (org-node-get-properties NODE) , no inherited properties |
Get subtree TODO state | (org-roam-node-todo NODE) | (org-node-get-todo NODE) , only that match global org-todo-keywords |
Get subtree SCHEDULED | (org-roam-node-scheduled NODE) | (org-node-get-scheduled NODE) |
Get subtree DEADLINE | (org-roam-node-deadline NODE) | (org-node-get-deadline NODE) |
Get outline-path | (org-roam-node-olp NODE) | (org-node-get-olp NODE) |
Get ROAM_REFS | (org-roam-node-refs NODE) | (org-node-get-refs NODE) |
Get ROAM_ALIASES | (org-roam-node-aliases NODE) | (org-node-get-aliases NODE) |
Get ROAM_EXCLUDE | (assoc "ROAM_EXCLUDE" (org-node-get-properties NODE)) , doesn’t inherit parent excludes! | |
Get whether this is a subtree | (zerop (org-roam-node-level NODE)) | (org-node-get-is-subtree NODE) |
Get subtree priority | (org-roam-node-priority NODE) | |
Ensure fresh data | (org-roam-db-sync) | (org-node-cache-ensure) |