How to Customize File Name and Path at Time of Capture

Hi Everyone,

This might be old hat to folks more familiar with elisp than I am, but I spent the afternoon working out a way to create a new org-roam note in a directory selected from a prompt (plus with a prefix to the filename, but I imagine that will be less helpful to other folks). I hope this is helpful to someone else at some point, and if you have any questions or want to point out how horribly broken my code is, please let me know!

tl;dr By defining a couple of functions and learning how to incorporate a function into the :file-name keyword (like this: "%(function)"), I learned how to customize the name and file path at the time of capture. I found this out by reading this post on slack.

Here’s what’s below, in case you want to skip ahead

  • Summary of Key Learnings from Below that Were Not Obvious to Me
  • My Problem Came from Using Subdirectories for Organization
  • My Capture Template Lets me Enter the Subdirectory When I Enter C-c n f

Summary of Key Learnings from Below that Were Not Obvious to Me

  • To prompt user for open ended content use (read-string "Prompt: ")
  • To prompt user to select an option from a list of choices, use (completing-read "Prompt: " list-of-choices)
  • To include a function in an org-roam-capture template’s :file-name keyword definition, put a percent sign ("%") in front of the opening parenthesis of that function inside a string, like so :file-name "%(function-name)-${slug}"

My Problem Came from Using Subdirectories for Organization

Now, first off, I know that Org-roam encourages a flat file structure, hopefully with no more than a place for fleeting thoughts and one for permanent notes. Partially due to previous habits, and partially because I keep notes on topics as diverse as business communication and a D&D campaign setting, I like to have sub-directories to help make browsing notes (rather than searching or following links) easier. My slipbox has 7 sub-boxes, so it seemed excessive to make a different capture template for each sub-directory. I had figured out how to make this work as a standard org-capture template, but I wanted to take advantage of org-roam-capture's extra features.

Unfortunately, it wasn’t immediately clear to me how to incorporate an elisp function into the :file-name keyword, and my first few attempts came back with errors. Fortunately, that led me to poke around the org-roam source code and learn a thing or two.

My Capture Template Lets me Enter the Subdirectory When I Enter C-c n f

Okay, besides the template itself below, I had to create a function to ask which sub-directory you want. Xah’s website has been a big help to me in learning Emacs and once again helped me out, this time with figuring out how to select input text from a list.

Here’s the function to prompt you to enter a sub-directory. The key learning for this for me was the use (completing-read) combined with a list of possible choices. I manually populated the choices, but if I were clever and spent a bit more time on it, I could likely populate the choices list with the contents of my Slipbox directory.
BTW, It doesn’t actually need to be (interactive) (it doesn’t do anything except print a message in the mini-buffer if you call it that way), but that made it easier for me to test it as I was debugging it.

  (defun jpr-slipbox-select ()
    "Select which slipbox in which to file a slip created with org-roam-capture."
    (let ((choices '("0-Fleeting_Thoughts" "1-Main" "2-Fellhold" "3-Runes" "4-Slush" "5-Games" "6-Dreams" "7-Story_Toolbox")))
      (let ((slipbox (completing-read "Slipbox Name: " choices)))
        (format "%s" slipbox))))

Here’s the function to prompt you to enter a Slip ID (this is a holdover from a previous system I used and helps visually organize things in Treemacs for browsing - if you’re plenty happy with the date/timestamp as a unique id, you can leave this bit out). The key learning for me was how to use (read-string), since I had forgotten how to use it since reading the built-in elisp tutorial a few months ago.
Again, interactive is not necessary here, but made it easier to debug.

  (defun jpr-pick-slip-id ()
    "Write out a slip ID for a newly created slip."
    (let ((slip-id (read-string "Slip ID: ")))
      (format "%s" slip-id)))

And here’s the function that puts both of those together, along with the non-variable elements. Note that for me “Slipbox/” is a sub-directory of my org-roam directory. If you want to adapt this to create sub-directories only one level deep in your default org-roam directory, you don’t need that part or an equivalent. No real new learnings here, just created a function to make make the template below cleaner.

(defun jpr-org-roam-slip-name ()
 "Make a name for a new slip, including which slipbox it goes in, for org-roam-capture."
 (concat "Slipbox/" (jpr-slipbox-select) "/" (jpr-pick-slip-id)))

And here’s the template itself. Note that the only part that is particularly relevant to this how to is the :file-name keyword assignment. The key learning for me was that if you want a function as part of the filename (so that it can be evaluated at the time of capture), you put a “%” before the opening parenthesis of the function - you can see that I’ve put a comment to remind myself of that in case I come back looking for how I did this when I want to build another template later. I’m familiar with the use of “%” as an indicator in a string that the following content is a symbol for expansion, but I don’t know if this use is specific to org-roam-capture, org-capture, or if it’s more broadly applicable to elisp. In any case, the place where I found an example of code that showed me the way was on the org-roam slack channel here.

          ("s" "Slip" plain #'org-roam-capture--get-point
           "** Body

** Where From
   :VISIBILITY: folded

** Links
   :VISIBILITY: folded

** Tags
   :VISIBILITY: folded

           :file-name "%(jpr-org-roam-slip-name)-${slug}"; the way you put functions into this keyword is to put a '%' before the opening parenthesis, enclosed within a string
           :head "#+title: ${title}\n#+roam_alias: \n#+roam_tags: \n\n#+PROPERTY: Slip_ID NNNN\n#+PROPERTY: Firstness 50\n#+PROPERTY: DATE %<%Y-%m-%d%H%M>\n\n"
           :unnarrowed t)

Looks like that will work, the only problem I faced when I used something like this was the inability of creating new subfolders on the fly. So I used (read-directory-name) instead.

("p" "Permanent (prompt folder)" plain (function org-roam-capture--get-point)
       :file-name "%(read-directory-name \"path: \" org-roam-brain-directory)/%<%Y%m%d%H%M>-${slug}"
       :head "#+title: ${title}\n#+author: %(concat user-full-name)\n#+email: %(concat user-mail-address)\n#+created: %(format-time-string \"[%Y-%m-%d %H:%M]\")\n#+roam_tags:\n\n%?"
       :unnarrowed t)))
1 Like

That looks a heck of a lot more elegant than what I have above - looks like I reinvented the wheel a bit by not being familiar with (read-directory-name) - very nice!

I had started thinking about how to write something more flexible/less hard-coded, but it looks like the right answer was “know better what pre-existing functions to use”.

1 Like