Emacs: use Denote for meetings or events

Some users want to know how they should use Denote to handle recurring events, such as meetings with colleagues. With Denote, we typically create a new file and write whatever we need there. But these kind of files start to lose value when they have the same title and keywords across time. For example, we cannot tell much by looking at a list of files like this:

- 20240915T105532--socratis__meeting.org
- 20240916T161208--platon__meeting.org
- 20240917T133627--aristotelis__meeting.org
- 20240918T091245--socratis__meeting.org
- 20240919T112950--platon__meeting.org
- 20240920T165532--aristotelis__meeting.org

Those files all look the same and it is impossible for us to make any sense of them unless we go through all the contents. So either we will use more descriptive titles and meaningful keywords, or we need to write some custom code that does the following:

  • Ask for the name of a person among a list of known names.
  • Find the file that mentions this name. If our files always have a certain keyword, like _meeting, we can factor this into our code (I include an example below).
  • If there are multiple files that correspond to the selected person, select one among them.
  • Open the file that is about that person.
  • Go to the bottom of that file and write a new heading with the current date and time (or something else, but I use this for the example—contact me if you have different requirements).

This way, we can keep one file per person/event to (i) still have the benefits of using Denote while (ii) not being forced to write many files that are virtually indistinguishable in a file listing.

The complete version of the code we need

Here is the entirety of it. In the next section I explain each bit in more detail. You want to include this in your configuration, evaluate it, modify the my-denote-colleagues to include the names you care about, and then use the command my-denote-colleagues-new-meeting.

(defvar my-denote-colleagues '("socratis" "platon" "aristotelis")
  "List of names I collaborate with.
There is at least one file in the variable `denote-directory' that has
the name of this person.")

(defvar my-denote-colleagues-prompt-history nil
  "Minibuffer history for `my-denote-colleagues-new-meeting'.")

(defun my-denote-colleagues-prompt ()
  "Prompt with completion for a name among `my-denote-colleagues'.
Use the last input as the default value."
  (let ((default-value (car my-denote-colleagues-prompt-history)))
    (completing-read
     (format-prompt "New meeting with COLLEAGUE" default-value)
     my-denote-colleagues
     nil :require-match nil
     'my-denote-colleagues-prompt-history
     default-value)))

(defun my-denote-colleagues-get-file (name)
  "Find file in variable `denote-directory' for NAME colleague.
If there are more than one files, prompt with completion for one among
them.

NAME is one among `my-denote-colleagues'."
  (if-let ((files (denote-directory-files name))
           (length-of-files (length files)))
      (cond
       ((= length-of-files 1)
        (car files))
       ((> length-of-files 1)
        (completing-read "Select a file: " files nil :require-match)))
    (user-error "No files for colleague with name `%s'" name)))

(defun my-denote-colleagues-new-meeting ()
  "Prompt for the name of a colleague and insert a timestamped heading therein.
The name of a colleague corresponds to at least one file in the variable
`denote-directory'.  In case there are multiple files, prompt to choose
one among them and operate therein.

Names are defined in `my-denote-colleagues'."
  (declare (interactive-only t))
  (interactive)
  (let* ((name (my-denote-colleagues-prompt))
         (file (my-denote-colleagues-get-file name))
         (time (format-time-string "%F %a %R")))  ; remove %R if you do not want the time
    (with-current-buffer (find-file file)
      (goto-char (point-max))
      ;; Here I am assuming we are in `org-mode', hence the leading
      ;; asterisk for the heading.  Adapt accordingly.
      (insert (format "* [%s]\n\n" time)))))

All the code with accompanying commentary

First we define a list of known names. We do this so that we can benefit from minibuffer completion. We could do without completion, but I think this is more convenient. The value of this variable is a list of strings.

[ Note that when we write actual packages we use defcustom for stuff that are meant to be customised by the user. Those variables are called “user options”. The code base of denote is a case study of good practices for packaging your Elisp code. ]

(defvar my-denote-colleagues '("socratis" "platon" "aristotelis")
  "List of names I collaborate with.
There is at least one file in the variable `denote-directory' that has
the name of this person.")

Then we have the minibuffer prompt that uses minibuffer completion with the names we defined above:

(defvar my-denote-colleagues-prompt-history nil
  "Minibuffer history for `my-denote-colleagues-new-meeting'.")

(defun my-denote-colleagues-prompt ()
  "Prompt with completion for a name among `my-denote-colleagues'.
Use the last input as the default value."
  (let ((default-value (car my-denote-colleagues-prompt-history)))
    (completing-read
     (format-prompt "New meeting with COLLEAGUE" default-value)
     my-denote-colleagues
     nil :require-match nil
     'my-denote-colleagues-prompt-history
     default-value)))

In the Emacs minibuffer, we have a history that we can access with M-p (previous-history-element) and M-n (next-history-element). If prompts do not have their own history variable, then we cycle through a shared history of inputs. This is generally not helpful, so practically all my minibuffer prompts have their own history.

We can persist minibuffer histories across sessions by using the built-in savehist-mode.

Also notice how the prompt has a default value, which is the last input we provided. The idea is that we can type RET to get this default if the minibuffer is empty. If this does not work with your completion framework, you will need to check how to input nothing at the prompt.

The next part is a function that finds the file associated with the name of the person we selected at the previous prompt.

(defun my-denote-colleagues-get-file (name)
  "Find file in variable `denote-directory' for NAME colleague.
If there are more than one files, prompt with completion for one among
them.

NAME is one among `my-denote-colleagues'."
  (if-let ((files (denote-directory-files name))
           (length-of-files (length files)))
      (cond
       ((= length-of-files 1)
        (car files))
       ((> length-of-files 1)
        (completing-read "Select a file: " files nil :require-match)))
    (user-error "No files for colleague with name `%s'" name)))

I have made it here so that we search the denote-directory for files that match name. So it would be something like this:

(denote-directory-files "socratis")

If we want to be more precise with what we are matching to, for example, only include files that have the _meeting keyword as well as the given name, then we can modify the relevant snippet in the my-denote-colleagues-get-file:

(denote-directory-files (format "%s.*_meeting" name))

[ Of course, we can make the entire query a parameter but I want to have a streamlined experience with this. ]

Let’s check again this function now:

(defun my-denote-colleagues-get-file (name)
  "Find file in variable `denote-directory' for NAME colleague.
If there are more than one files, prompt with completion for one among
them.

NAME is one among `my-denote-colleagues'."
  (if-let ((files (denote-directory-files name)) ; OR (denote-directory-files (format "%s.*_meeting" name))
           (length-of-files (length files)))
      (cond
       ((= length-of-files 1)
        (car files))
       ((> length-of-files 1)
        (completing-read "Select a file: " files nil :require-match)))
    (user-error "No files for colleague with name `%s'" name)))

You will notice that it has a completing-read inside of it in the case where the number of files we fin is greater than 1. This completing-read is what we use in the prompt we defined earlier, but here we do not use a history because the chances that we need one in such a workflow are few—if you do need a dedicated history, check how we did it above and adapt accordingly (though I think it makes sense to consolidate those files and ultimately not have to select anything at this stage).

Now that we have set up all the helper functions, we can check the final piece, which is what you will call with M-x or a key binding:

(defun my-denote-colleagues-new-meeting ()
  "Prompt for the name of a colleague and insert a timestamped heading therein.
The name of a colleague corresponds to at least one file in the variable
`denote-directory'.  In case there are multiple files, prompt to choose
one among them and operate therein.

Names are defined in `my-denote-colleagues'."
  (declare (interactive-only t))
  (interactive)
  (let* ((name (my-denote-colleagues-prompt))
         (file (my-denote-colleagues-get-file name))
         (time (format-time-string "%F %a %R")))  ; remove %R if you do not want the time
    (with-current-buffer (find-file file)
      (goto-char (point-max))
      ;; Here I am assuming we are in `org-mode', hence the leading
      ;; asterisk for the heading.  Adapt accordingly.
      (insert (format "* [%s]\n\n" time)))))

[ In the Denote code base we have lots of examples of using the interactive spec to make the function accept arguments from Lisp but also in interactive uses. Here I am keeping it simple to not go into too many technicalities. ]

The above command ties together everything we have covered thus far. It then adds the final element, which is the heading at the bottom with the current date and time. If you want to modify how that looks, check the format-time-string. And if you want something else entirely at this point, just let me know and I will help you make it happen.

I wrote a variant of this in the Denote manual in response to a question by Kosta Harlan. This was done via a private channel and the information is shared with permission.

Denote sources

Denote is a simple note-taking tool for Emacs. It is based on the idea that notes should follow a predictable and descriptive file-naming scheme. The file name must offer a clear indication of what the note is about, without reference to any other metadata. Denote basically streamlines the creation of such files while providing facilities to link between them.

Denote’s file-naming scheme is not limited to “notes”. It can be used for all types of file, including those that are not editable in Emacs, such as videos. Naming files in a consistent way makes their filtering and retrieval considerably easier. Denote provides relevant facilities to rename files, regardless of file type.