Org-roam fixes

Some mods to #org-roam I've felt necessary.

Skip prompting for a filename

It's enough that you gave a title, shouldn't also have to confirm the filename, right?

(setq org-roam-capture-templates
      '(("d" "default" plain "%?" :if-new
         (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
                    "#+title: ${title}\n#+filetags: :noexport:stub:"))
        :immediate-finish t
        :jump-to-captured t))

Quick stubsπŸ”—

Situation: You're in the middle of writing and you realize you want to link to a node that doesn't yet exist.

I have the following capture template for that. It makes a blank new file without visiting it, so you can pick it on the org-roam-node-insert screen and be on your merry way.

(add-to-list 'org-roam-capture-templates
             '("i" "instantly create this node" plain "%?" :if-new
               (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
                          "#+title: ${title}\n#+filetags: :stub:\n")
               :immediate-finish t)

PerformanceπŸ”—

Org-roam gets slow as you accumulate thousands of nodes. The following code fixes it. Requires the packages memoize and vulpea.

Vulpea provides faster versions of org-roam commands, but it depends on its own optimized database. The first time you enable vulpea-db-autosync-mode, you need to eval (org-roam-db-sync 'force) so it can populate that database.

;; Make the commands `org-roam-node-find' & `org-roam-node-insert' faster and
;; often instant.

;; Small drawback: after you just created a node, you can't immediately
;; find it as it won't be in the cache.  You must leave Emacs alone for
;; 10 seconds, then it'll enter the cache.

(defun my-vulpea-memo-refresh ()
  (memoize-restore #'vulpea-db-query)
  (memoize         #'vulpea-db-query)
  (vulpea-db-query nil))

(defvar my-vulpea-memo-timer (timer-create))
(defun my-vulpea-memo-schedule-refresh (&rest _)
  "Schedule a re-caching when the user is idle."
  (cancel-timer my-vulpea-memo-timer)
  (setq my-vulpea-memo-timer
        (run-with-idle-timer 10 nil #'my-vulpea-memo-refresh)))

;; Love this lib. Thank you
(use-package vulpea
  :hook ((org-roam-db-autosync-mode . vulpea-db-autosync-enable))
  :bind (([remap org-roam-node-find] . vulpea-find)
         ([remap org-roam-node-insert] . vulpea-insert))
  :config
  (use-package memoize :demand)
  (memoize #'vulpea-db-query)
  (advice-add 'org-roam-db-update-file :after 'my-vulpea-memo-schedule-refresh))

Other speed boosts

;; Don't search for "roam:" links (it slows saving on large files)
(setq org-roam-link-auto-replace nil)

;; Speed up `org-roam-db-sync'
;; NOTE: `setopt' breaks it, use `setq'
;; NOTE: Counterproductive on Windows
(setq org-roam-db-gc-threshold most-positive-fixnum)

If you still find it slow to save large files, maybe you have org-crypt configured. It's that package that encrypts/decrypts subtrees.

Doom Emacs' Org module ships it by default, so here's how to disable it in Doom's packages.el:

(package! org-crypt :disable t)

Custom org-roam-extract-subtreeπŸ”—

I encourage you to maintain a personal variant of org-roam-extract-subtree. It's a pleasure to have one that does everything right.

Here's mine. Compare with the original (type M-x find-function org-roam-extract-subtree RET). Differences from the default command:

  1. Actually make a file-level node, i.e. a file with :PROPERTIES: at the top, instead of a plain file with a top-level heading
  2. Skip prompting for a title – just reuse the heading as title
  3. Copy the original file's :CREATED: property if the subtree didn't already have its own (I use this property everywhere because it's not as if you can rely on filesystem ctime)
  4. Copy all the parent tags
    • Only if org-use-tag-inheritance is t
    • If there were no tags anywhere, add a :noexport: tag. That's normally what I want for new nodes (Slipbox workflow).
  5. Add a #+date: for me to fill in later
    • I use this field to indicate the date of "last meaningful update", thus it becomes Last Updated on my blog. Could use file modification time, but it is not meaningful.
  6. Show the resulting file to me, so I can verify it looks right
  7. Leave a link in the original file, where the subtree was
(defun my-org-roam-extract-subtree ()
  "Variant of `org-roam-extract-subtree'.
It skips prompting, and inserts the metadata I want."
  (interactive)
  (save-excursion
    (org-back-to-heading-or-point-min t)
    (when (bobp) (user-error "Already a top-level node"))
    (org-id-get-create)
    (save-buffer)
    (org-roam-db-update-file)
    (let* ((template-info nil)
           (node (org-roam-node-at-point))
           ;; Determine filename based on `org-roam-extract-new-file-path'
           (template (org-roam-format-template
                      (string-trim (org-capture-fill-template
                                    org-roam-extract-new-file-path))
                      (lambda (key default-val)
                        (let ((fn (intern key))
                              (node-fn (intern (concat "org-roam-node-" key))))
                          (cond
                           ((fboundp fn)
                            (funcall fn node))
                           ((fboundp node-fn)
                            (funcall node-fn node))
                           (t (let ((r (read-from-minibuffer (format "%s: " key) default-val)))
                                (plist-put template-info ksym r)
                                r)))))))
           (file-path
            (expand-file-name template org-roam-directory))
           (parent-tags (org-get-tags))
           (parent-creation (save-excursion
                              (goto-char (point-min))
                              (org-entry-get nil "CREATED"))))
      (when (file-exists-p file-path)
        (user-error "%s exists. Aborting" file-path))
      (org-cut-subtree)
      (open-line 1)
      (insert "- " (org-link-make-string
                    (concat "id:" (org-roam-node-id node))
                    (org-roam-node-formatted node)))
      (save-buffer)
      (find-file file-path)
      (org-paste-subtree)
      (while (> (org-current-level) 1)
        (org-promote-subtree))
      (save-buffer)
      (org-roam-promote-entire-buffer)
      (goto-char (point-min))
      (unless (org-entry-get nil "CREATED")
        (org-set-property "CREATED" (or parent-creation
                                        (format-time-string "[%F]"))))
      (org-roam-tag-add (or parent-tags
                            '("noexport")))
      (search-forward "#+title")
      (goto-char (line-beginning-position))
      (if (version<= "29" emacs-version)
          (ensure-empty-lines 0)
        (when (looking-back "\n\n")
          (join-line)))
      (search-forward "#+filetags" nil t)
      (forward-line 1)
      (open-line 2)
      (insert "#+date:")
      (save-buffer))))

Custom org-open-at-pointπŸ”—

Before you can start to use roam refs confidently, or even really understand what they are, you need a picture in your head of how they will make your life easier.

Part of my picture was that I should be able to insert a raw URL anywhere and then if I click on it, Emacs will jump to the corresponding roam-node if I happen to have one that refs that URL.

This replacement for org-open-at-point does that.

(define-key global-map [remap org-open-at-point]
            #'my-org-open-at-point-as-maybe-roam-ref)
(defun my-org-open-at-point-as-maybe-roam-ref (&optional arg)
  "Like `org-open-at-point', but prefer to visit any org-roam node
that has the link as a ref.
If already visiting that same node, then follow the link normally."
  (interactive "P")
  (let* ((url (thing-at-point 'url))
         (path (if (derived-mode-p 'org-mode)
                   (org-element-property :path (org-element-context))
                 (replace-regexp-in-string (rx bol (* (not "/"))) "" url)))
         (all-refs (org-roam-db-query
                    [:select [ref id]
                     :from refs
                     :left-join nodes
                     :on (= refs:node-id nodes:id)]))
         (found (when path (assoc path all-refs))))

    (if (and found
             ;; check that the ref does not point to THIS file (if so, better to
             ;; just open the url normally)
             (not (when (derived-mode-p 'org-mode)
                    (equal (cdr found)
                           (or (org-id-get)
                               (progn
                                 (goto-char (point-min))
                                 (org-id-get)))))))
        (org-roam-node-visit (org-roam-node-from-id (cadr found)))
      (if arg
          (org-open-at-point arg)
        (org-open-at-point)))))

Command to auto-rename file based on #+TITLEπŸ”—

In Org-roam, nodes are named by the #+title line – it doesn't care about the name of the files on the filesystem.

However, the filename does influence the exported HTML filenames, which determines your blog post addresses.

With the following command, you can type M-x my-rename-roam-file-by-title whenever you want to update the filename.

(defun my-rename-roam-file-by-title (&optional path)
  "Rename file in current buffer, based on its Org
#+title property.

Can also take a file PATH instead of current buffer."
  (interactive)
  (unless path
    (setq path (buffer-file-name)))
  (unless (equal "org" (file-name-extension path))
    (user-error "File doesn't end in .org: %s" path))
  (let* ((visiting (find-buffer-visiting path))
         (on-window (and visiting (get-buffer-window visiting)))
         (slug
          (with-current-buffer (or visiting (find-file-noselect path))
            (goto-char (point-min))
            (let ((node (org-roam-node-at-point t)))
              (unless (org-roam-node-title node)
                (user-error "Node not yet known to org-roam DB"))
              (org-roam-node-slug node))))
         (new-path (expand-file-name (concat slug ".org")
                                     (file-name-directory path))))
    (if (equal path new-path)
        (message "Filename already correct: %s" path)
      (if (and visiting (buffer-modified-p visiting))
          (message "Unsaved file, letting it be: %s" path)
        (unless (file-writable-p path)
          (error "No permissions to rename file: %s" path))
        (unless (file-writable-p new-path)
          (error "No permissions to write a new file at: %s" new-path))
        ;; Kill buffer before renaming, to be safe
        (when visiting
          (kill-buffer visiting))
        (rename-file path new-path)
        (prog1 (message "File %s renamed to %s"
                        (file-name-nondirectory path)
                        (file-name-nondirectory new-path))
          ;; Visit the file again if you had it open
          (when visiting
            (let ((buf (find-file-noselect new-path)))
              (when on-window
                (set-window-buffer on-window buf)))))))))

Command to rename an image asset and rewrite all links pointing to it

(defun my-rename-roam-asset-and-rewrite-links ()
  (interactive)
  (require 'dash)
  (when (or (equal default-directory org-roam-directory)
            (when (yes-or-no-p "Not in org-roam-directory, go there?")
              (find-file org-roam-directory)
              t))
    (when-let ((bufs (--filter (string-search "*grep*" (buffer-name it))
                               (buffer-list))))
      (when (yes-or-no-p "Some *grep* buffers, kill to be sure this works?")
        (mapc #'kill-buffer bufs)))
    (let* ((filename (file-relative-name
                      (read-file-name "File: ") org-roam-directory))
           (new (read-string "New name: " filename)))
      (mkdir (file-name-directory new) t)
      (unless (file-writable-p new)
        (error "New path wouldn't be writable"))
      (rgrep (regexp-quote filename) "*.org")
      (run-with-timer
       1 nil
       (lambda ()
         (save-window-excursion
           (delete-other-windows)
           (switch-to-buffer (--find (string-search "*grep*" (buffer-name it))
                                     (buffer-list)))
           (wgrep-change-to-wgrep-mode)
           (goto-char (point-min))
           (query-replace filename new)
           (wgrep-finish-edit)
           (when (y-or-n-p "Finished editing links, rename file?")
             (rename-file filename new)
             (message "File renamed from %s to %s" filename new)))))
      (message "Waiting for rgrep to populate buffer..."))))
Created (7 months ago)
Updated (3 months ago)