Random ramblings, mostly technical

Wiktor writes about stuff.

Org-roam: custom linking during capture

2024-08-16

After using plain Org Mode for a few years and a brief experiment with Logseq1, I finally settled with Org-roam for my work notes and task management.

Org-roam builds on Emacs’ org-mode, adding functionality known from tools like Roam Research or Obsidian. Notes can be linked with each other, creating a “knowledge graph”2.

After some initial hurdles, I ended up with leveraging the “dailies” feature. Daily note contains headings with tickets I work on, linked to their respective nodes.

Org-roam daily buffer, containing headings that link to Jira tickets.

With this method, it is easy to track (and report) my work by just reviewing the journal files. There is a small downside - if I would like to see my progress on a specific task, I need to use the backlinking feature (with org-roam-buffer-toggle in the task node). It is nice for a quick glance, but for longer analysis and reporting I found it tedious. I even wrote some ugly Elisp to generate reports, but it still felt off.

Few days ago, I finally found some time to work on this and gathered the following requirements:

  • daily notes related to a task should land in its own node,
  • task node should contain a Log heading, with timestamped subheadings linking to a daily node,
  • capturing a task entry should automatically add a link to the task in a daily node.

To implement this behavior, two entry points were used:

  • function being called with org-capture-after-finalize-hook, that will call another capture, adding a link to the daily note,
  • a custom org-roam capture target, leveraging org-roam custom references to insert a heading with a link.

Starting with the second one:

(setq org-roam-capture-templates
      '(
        ("t" "Task note" entry
         "**** %?"
         :target (file+head+olp "pages/${slug}.org"
             "#+title: ${title}\n\n" ("Log" "${roam-daily-link}"))
         :unnarrowed t
         :prepend nil)
        ))

The ${roam-daily-link} is a custom template expansion I added:

;; Temporary hook to close daily after capture:
(defun temp-save-close-daily-hook ()
(save-buffer)
(kill-buffer))

;;Use ${roam-daily-link} to insert a link to today's daily
(defun roam-daily-link (node)
(let* ((l/daily-title
        (format-time-string "%Y-%m-%d %a" (current-time)))
        (l/daily-node
        (org-roam-node-from-title-or-alias l/daily-title)))
    (save-excursion
    (if (not (eq nil l/daily-node))
        (format "[[id:%s][%s]]"
                (org-roam-node-id l/daily-node)
                l/daily-title)
        (progn
        (add-hook
        'org-roam-dailies-find-file-hook
        #'temp-save-close-daily-hook)
        (org-roam-dailies-capture-today t "d")
        (remove-hook
        'org-roam-dailies-find-file-hook
        #'temp-save-close-daily-hook)
        (setq l/daily-node
                (org-roam-node-from-title-or-alias l/daily-title))
        (format "[[id:%s][%s]]"
                (org-roam-node-id l/daily-node)
                l/daily-title))))))

The problem with inserting a daily link is that it could not exist yet. I might need to create it first (with org-roam-dailies-capture-today), but to avoid opening the daily buffer I need to temporarily hook org-roam-dailies-find-file-hook to save and close the buffer.

The last missing part is the capture finalization hook:

 (defun wigol/org-roam-capture-in-journal-finalize-hook ()
   "Adds the captured task file to the daily as a link"
   ;; If:
   ;; - the capture was confirmed,
   ;; - we are not in internal capture,
   ;; - current template supports daily logging.
   (unless (or org-note-abort
               (string= (org-capture-get :key) "ATC")
               (not
                (member
                 (org-capture-get :key)
                 my-org-roam-capture-in-journal-supported-keys)))
     (with-current-buffer (org-capture-get :buffer)
       (let ((l/name (file-name-sans-extension (buffer-name)))
             (l/link
              (format "[[id:%s][%s]]"
                      (org-roam-id-at-point)
                      (file-name-sans-extension (buffer-name))))
             (l/daily-file
              (concat
               (file-name-as-directory org-roam-directory)
               (file-name-as-directory org-roam-dailies-directory)
               (format-time-string "%Y_%m_%d" (current-time)) ".org"))
             (l/already-filed nil)

             (org-roam-dailies-capture-templates
              '(("ATC" "After Task Capture" entry "* %c %?"
                 :target
                 (file+head
                  "%<%Y_%m_%d>.org" "#+title: %<%Y-%m-%d %a>")
                 :immediate-finish t))))
         ; Check if the top level heading already exists in the journal:
         (if (file-exists-p l/daily-file)
             (progn
               (save-excursion
                 (with-current-buffer (find-file-noselect
                                       l/daily-file)
                   (setq l/already-filed
                         (search-forward (format "* %s" l/link)
                                         nil
                                         t))))))
         (if (eq l/already-filed nil)
             (progn
               (kill-new l/link)
               (org-roam-dailies-capture-today nil "ATC")
               ;; Clean kill ring:
               (when kill-ring
                 (setq kill-ring (cdr kill-ring)))
               (when kill-ring-yank-pointer
                 (setq kill-ring-yank-pointer kill-ring))))))))

my-org-roam-capture-in-journal-supported-keys is a list of template keys, where I would like to have the daily logging behavior.

Since I do not want add a link to the daily every time I make a note in the task, I check first if it has already been logged at top level.

At least for now, I am satisfied with the result:

Org-roam task buffer, containing the following headings tree: Log - date linked to the daily note - note contents.

Org-roam daily buffer, containing two headings - link to task node and a to-do item.

Org-roam supports querying, also for relations between nodes. I guess the next step would be to use that for reporting.


  1. It was a slick experience, especially paired with some Elisp to make it work with org-roam (mostly for code blocks). In a recent roadmap update, however, they announced pulling the plug on org-mode format support. Back to Org-roam only, I guess… ↩︎

  2. using excelent org-roam-ui package. ↩︎