Keeping notes with Zetteldeft

One of my goals for 2020 was to try zettelkasten for managing my notes. I've experimented with various approaches in the past: paper notes in folders (and all over my desk), self-hosted wikis, and custom software like OneNote or Notion.

A few years ago I started using LionWiki to manage a small personal knowledge base. It was somewhat successful, but it didn't quite fit in with my daily workflow. It also required a network connection and browser to be open, and re-organizing notes wasn't quite as quick as I would like. I really like it as a lightweight wiki solution, but for personal notes I wanted something more.

Eventually I switched to deft, an Emacs-based tool for organizing notes that is based on Notational Velocity. This summer I evaluated a number of other zettelkasten tools, but I settled on Zetteldeft as it integrated well with my existing set of notes.

zetteldeft works alongside deft and adds support for special linking syntax, as well as a slightly different naming scheme for notes (timestamp followed by the title).

Zetteldeft open and ready to search
Figure 1: Click to see deft's search in action

There are a few things I really like about deft/zetteldeft's approach:

  • Text based notes – All of my notes are stored in org-mode format on my home computer. deft also supports plain text and markdown formats.
  • Fast to find notes – With a few keystrokes (C-c z D) I can bring up a new search, and the results are narrowed as I type.
  • Fast to add notes – C-c z n brings up a prompt for a new note title. Once that is entered, I'm taken to the note file with the title already filled in.
  • Linking to other notes is easy – zetteldeft supports linking to notes via a special character and the note id, which looks like this: ยง2018-07-09-2115. With some modifications it can also use a special zdlink protocol in org-mode.
  • Fits in with my daily work – If I've found the solution to a problem, I can quickly create a note and paste the solution without having to switch to another application or open a browser tab.

My zetteldeft configuration

The following code lives in my Emacs init.el. It sets up deft and zetteldeft, along with a bunch of keyboard shortcuts.

One additional change I made is binding C-c z p to sodaware/deft-open-preview. This allows me to open notes in a preview window without the list losing keyboard focus.

I use file-truename when setting my notes path as zetteldeft had some problems locating my notes when using a relative path.

(use-package deft
  (("C-c d" . deft))
  ;; Set deft path to full path so that zetteldeft works.
  (deft-directory         (file-truename "~/Documents/notes"))
  (deft-extensions        '("md" "org"))
  (deft-default-extension "org")
  (deft-recursive         t))

(use-package zetteldeft
  ("C-c z d" . deft)
  ("C-c z R" . deft-refresh)
  ("C-c z D" . zetteldeft-deft-new-search)
  ("C-c z s" . zetteldeft-search-at-point)
  ("C-c z c" . zetteldeft-search-current-id)
  ("C-c z f" . zetteldeft-follow-link)
  ("C-c z F" . zetteldeft-avy-file-search-ace-window)
  ("C-c z l" . zetteldeft-avy-link-search)
  ("C-c z t" . zetteldeft-avy-tag-search)
  ("C-c z T" . zetteldeft-tag-buffer)
  ("C-c z i" . zetteldeft-find-file-id-insert)
  ("C-c z I" . zetteldeft-find-file-full-title-insert)
  ("C-c z o" . zetteldeft-find-file)
  ("C-c z n" . zetteldeft-new-file)
  ("C-c z N" . zetteldeft-new-file-and-link)
  ("C-c z p" . sodaware/deft-open-preview)
  ("C-c z r" . zetteldeft-file-rename)
  ("C-c z x" . zetteldeft-count-words)
  (defun sodaware/deft-open-preview ()

   `((,zetteldeft-id-regex  . font-lock-warning-face)
     (,zetteldeft-tag-regex . font-lock-warning-face))))

Additional changes: the zdlink protocol

This is one of my favourite changes - it adds a new org-mode protocol, zdlink, which allows me to insert an org-style link to any note via C-c C-l. These links can be opened like any other org-mode link, and because they only include the note name (not the full path) they work across machines.

This isn't very useful if you're using markdown, but for pure org it makes linking and navigating much easier.

;; Add custom `zdlink` to handle zettledeft links.
(eval-after-load 'org
  (lambda ()
    (require 'zetteldeft)
     (lambda (str) (zetteldeft--search-filename
		    (zetteldeft--lift-id str)))
     :complete  #'sodaware/zd-complete-link
     :help-echo "Searches provided ID in Zetteldeft")))

(defun sodaware/zd-complete-link ()
  "Link completion for `zdlink' type links."
  (let* ((file (completing-read "File to link to: "
	 (link (zetteldeft--lift-id file)))
    (unless link (user-error "No file selected"))
    (concat "zdlink:" link)))

Additional feature: zetteldeft homepage

Note: This behaviour is now part of zetteldeft core. A home note can be assigned via the zetteldeft-home-id variable, and then accessed with M-x zetteldeft-go-home.

This is a small change that allows me to open a specific note file with a keyboard shortcut. This is useful when keeping a single note that acts as a gateway to all others.

I prefer the main search buffer for finding notes, but I keep one note that lists my main projects and links to their notes.

(defun sodaware/zd-homepage ()
  "Open Zetteldeft home file."
  (zetteldeft-find-file "2020-07-10-0959"))
(global-set-key (kbd "C-c z h") #'sodaware/zd-homepage)

Additional feature: .dir-locals.el config

This is a really simple .dir-locals.el file that lives in my main notes directory. It adds 3 features:

  • emojify-mode support – Sometimes I see fancy notion setups and want to spice up my buffers with some emoji. I don't, but with emojify-mode I could.
  • whitespace-mode - This removes trailing whitespace when I save notes.
  • Expands notes on opening – I normally have org-mode headlines collapsed as I use it for organizing my todo lists, but for notes I like to see the entire document when I open it.
((org-mode . ((mode . emojify)
	      (mode . whitespace-cleanup)
	      (eval . (outline-show-all)))))

How I use org-mode

A few days ago, someone in /r/emacs asked the following question:

Hi! I am getting started with org and wanted to ask you why do you use it and how did org changed your life/workflow.

Answering it made me realize just how much of my daily workflow uses org-mode. Even more interesting was how much I use it for non-task related work. This is an expanded version of my original answer.

Tracking my diet, weight, and workout routines

This is something I wrote about in July. I've used org-mode to track my diet for years; the combination of properties and inline tables makes it extremely flexible.

I also use org-mode for tracking my exercise routines. This is integrated with beeminder to keep me on track.

Keeping on top of recurring tasks

I use org-habits for tracking recurring tasks and habits that I want to create. It integrates with org-agenda and shows a progress bar of days I have completed it.

All of my habits are also tied to beeminder goals (via beeminder.el) to give me some additional incentive.

A single habit entry looks like this:

* TODO Weekly review
  SCHEDULED: <2020-08-28 Fri .+7d>
  :beeminder: weekly-review
  :STYLE:     habit
  :beeminder-skip-deadlines: true

Marking the task as completed sends a datapoint to beeminder, and because the task is a habit the TODO remains open for the next date. I use the :beeminder-skip-deadlines: property to prevent habit deadlines from clogging up my agenda.

Keeping work and personal notes

I've been using zetteldeft to create and organize my notes for a couple of months. I was using deft before, so there wasn't much of a learning curve. I really like how the search narrows things as I type, and linking notes together makes it easier to keep things organized.

I'm not quite as organized as some other systems I've seen, but for the amount of notes I have (~150) it works quite well.

The full setup deserves an entire post of its own.

My full setup is detailed here: "Keeping notes with zetteldeft".

Keeping track of important dates

I use org-anniversary and a single file called to store birthdays and anniversaries.

An entry in my looks like this:

* Anniversaries
  :CATEGORY: Important Dates

%%(org-anniversary 2020  6 16) Gingersnap's %d adoptaversary

Unlike a scheduled TODO item, these dates show up in the agenda with the correct anniversary or birthday number. So in 2021 this entry would show as Gingersnap's 1 adoptaversary.

Planning out projects and organizing my day

The combination of plaintext for tasks and the dynamic agenda are extremely powerful for organizing projects. The agenda shows everything I have scheduled for the current week, and I'll usually narrow it down to the current day to get a better view.

This is another thing that deserves its own post.

Writing my personal blog

I use org-mode's html exporter to convert posts to html. I wrote about this in "From WordPress to Jekyll", but that process is a little outdated.

My current system involves storing posts in git, and then building the html + site on the server whenever content is pushed to it. I should probably write a post about this one too.

If you found this helpful and would like to read more content like it, follow me on Twitter.

Groundhog Day Resolutions - August 2020

July was was busy. Really busy. I'm also convinced it was the shortest month of the year.

July's Primary Goals

1. Release version 0.3 of The Book

I wrote a lot of notes but didn't get them in a publishable state.

2. Run some more

I ran more miles in July than any month since March. I can't say I enjoyed running them, but the longer runs were not as painful as last time I trained for a half.

3. Release version 1.2 of beeminder.el

I finished a couple more features and straightened out some bugs, but there is one last part I need to get finished and tested before release.

July's Secondary Goals

1. Complete another "other goal"

Success, I planted a tree! Or rather, I donated money to an organization that plants trees. I would have preferred to do the planting myself, but given the various restrictions in place due to COVID-19 that wasn't looking likely.

2. Create a short screencast

I underestimated how difficult this was going to be. Not just from a technical point of view, the also the amount of writing and editing required.

This is still something I'd like to try, but I don't think it's something to attempt without more planning and research.

Primary Goals for August

1. Release version 0.3 of The Book

Let's try this one again. I really want to have the first complete edition published by the end of the year. There are still 4 months left, but that time goes by pretty darn quick.

2. Bodyweight workouts, 3 times a week

My October half marathon has been canceled, so there is no need for me to run as many miles as I had planned. I'll still be running, but only 3 times a week instead of 5 days like before. I'm okay with that.

To replace the running I'd like to get back into bodyweight workouts. I already have most of the equipment I need, which is handy given that everything is sold out.

I expect there will be some Beeminding involved with this goal.

3. Release version 1.2 of beeminder.el

Third time's a charm?

Secondary Goals for August

1. Publish 8 posts on this site

There are a couple of topics I've been meaning to write about but haven't made the time for. August seems like as good a time as any.

2. Maintain a frog stand for 30 seconds

Learning three bodyweight skills is one of my major goals for 2020, but I've been quite lax lazy about practicing them.

By the end of August I want to be able to maintain a frog stand for 30 seconds without falling over.

July absolutely flew by. Work was especially busy, but I still should have spent more time on my goals. I have a bad habit of setting GHD goals and then forgetting about them until it's too late.

For August I'll be putting them all into my GTD system (like I should have been doing). We'll see if that makes a difference.

Tracking my diet with Emacs - part II

In part one, I showed how I keep track of my diet using Emacs. In this part I'll explain how I extract that data into something that can be used outside of Emacs.

The full source code is available at the end of the post.

What it does

I wrote a small ruby script that converts all of my daily entries into a json file. It turns something like this:

** CAL-OUT Diet for day <2019-10-05 Sat>
  :Weight:   167.3

| Timestamp              | Food                   | Calories | Quantity |   Total |
| [2019-10-05 Sat 08:42] | Cup of tea             |       54 |        1 |      54 |
| [2019-10-05 Sat 11:34] | Chocolate Chip Pancake |      2.7 |      202 |   545.4 |
| [2019-10-05 Sat 14:46] | Cup of tea             |       54 |        1 |      54 |
| [2019-10-05 Sat 14:46] | Vanilla yogurt raisins |     4.33 |       30 |   129.9 |
| [2019-10-05 Sat 17:45] | Beef meatballs         |     2.02 |      370 |   747.4 |
| [2019-10-05 Sat 17:45] | Fresh Pasta            |     1.31 |      306 |  400.86 |
| [2019-10-05 Sat 17:45] | Garlic bread           |     2.75 |       94 |   258.5 |
| [2019-10-05 Sat 19:58] | Cup of tea             |       54 |        1 |      54 |
| Total                  |                        |          |          | 2244.06 |
#+TBLFM: $5=$3*$4::@>$5=vsum(@2$5..@-I$5)

into this:

    "keyword": "CAL-OUT",
    "date": "2019-10-05 Sat",
    "properties": {
      "Weight": "167.3"
    "entries": [
	"[2019-10-05 Sat 08:42]",
	"Cup of tea",
	"[2019-10-05 Sat 11:34]",
	"Chocolate Chip Pancake",
	"[2019-10-05 Sat 14:46]",
	"Cup of tea",
	"[2019-10-05 Sat 14:46]",
	"Vanilla yogurt raisins",
	"[2019-10-05 Sat 17:45]",
	"Beef meatballs",
	"[2019-10-05 Sat 17:45]",
	"Fresh Pasta [Ronzoni Homestyle]",
	"[2019-10-05 Sat 17:45]",
	"Garlic bread",
	"[2019-10-05 Sat 19:58]",
	"Cup of tea",

I went with json as there are far more libraries available that parse json compared to org-mode, which makes it easier to use the data elsewhere.

Other potential solutions

There were a couple of other ideas I considered.

1. Write an elisp org-mode exporter

org-mode has a very flexible exporting system; it's what turns my .org blog posts into html so that Jekyll can publish them. There are ten exporters included with org-mode, including exporters for html, latex, and OpenDocument.

This was my first idea, but my elisp knowledge in 2012 was limited at best. There's also the issue that Emacs has to start up and run to extract data this way. It's fine if I'm exporting data whilst using Emacs, but not quite as quick if I'm running it from the command line.

It might be something I try again in the future now that I have more lisp experience.

2. Write a script in a different language

I went with ruby as it has an easy-to-use org-mode library that is fairly up-to-date. There are other org-mode parsers available, but not all of them are as robust as ruby's.

I'd like to try the common lisp parse, cl-org-mode, at some point.

3. Export the content to SQLite instead

Json is useful for passing data around, but a dedicated database with SQL is a better tool when it comes to querying. I've worked with SQLite for smaller projects and it's always quick to get started with.

Ruby has some nice libraries for interacting with SQLlite, so it could be integrated fairly easily into my existing script. Emacs also has SQL mode which can query databases and return the results into a buffer.

The full script

The script requires the following gems to be installed:

It wasn't designed to be used as a standalone script as it's part of a larger library that I use to build this site. It's also not as ruby-fied as I would like, but it does the job for now.


Add the following to the bottom of the full script, replacing f.input and f.output with the full input and output paths of the diet file.

extractor = do |f|
  f.from     = '2020-01-01'       = '2020-12-31'
  f.keywords = %w[CAL-OUT CAL-CANCEL]
  f.headline = 'Diet for day'
  f.input    = ''
  f.output   = 'diet.json'


Available configuration variables are:

Any entries made before this date will be excluded from the export file.
Like from, except it excludes entries made after this date.
An array of headline keywords to include in the export. Any headlines with a different keyword (such as CAL-IN) will be excluded.
The starting words of headlines to include.
The full path of the diet file to parse.
The full path where the json data should go.

The script

# frozen_string_literal: true

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <>.

require 'json'
require 'org-ruby'

class OrgDietExtractor
  attr_accessor :format
  attr_accessor :input
  attr_accessor :output
  attr_accessor :from
  attr_accessor :to
  attr_accessor :headline
  attr_accessor :keywords
  attr_accessor :current_parent
  attr_accessor :current_level

  def initialize
    yield self

  def headline_contents(headline)
    text_lines = headline.body_lines

    # Strip properties stuff.
    text_lines = do |line|
      task_line = line.output_text.strip
      next if task_line.start_with?(':')


    text_lines = text_lines.reject { |c| c.nil? || c.empty? }

  def belongs_to?(headline)
    @current_parent == headline

  def entry_in_range?(headline)
    return true unless @from || @to

    # Extract the dates
    date       = Date.parse(diet_date(headline))
    start_date = Date.parse(@from) if @from
    end_date   = Date.parse(@to) if @to

    return false if start_date && date < start_date
    return false if end_date && date > end_date


  def extract_table_headers(content)
    header_line = content.split("\n").first
    return if header_line.nil?

    header_cols = header_line.split('|').map(&:strip)

    header_cols.reject { |col| col.empty? }

  def extract_table_rows(content)
    table_lines = content.split("\n")
    return if table_lines.nil?

    # Remove first + last line
    table_lines = table_lines.drop(2)
    table_lines.pop(3) do |line|

  def diet_node?(headline)
    @keywords.include?(headline.keyword) && headline.headline_text.start_with?(@headline)

  def diet_date(headline)

  def diet_properties(headline)

  def diet_entries(contents)
    headers = extract_table_headers(contents)
    return nil unless headers

    table = []
    table << headers
    table += extract_table_rows(contents)


  def remap_property(task, from, to)
    return unless task[:properties].key?(from)

    task[to] = task[:properties][from]

  def generate()
    # Get full paths.
    input_file  = File.absolute_path(@input)
    output_file = File.absolute_path(@output)

    # Load the org file and parse it.
    file_in =
    doc     =

    # Parse all headlines.
    diet_days = []

    doc.headlines.each do |headline|
      # Want top-level headlines in date range only.
      next unless diet_node?(headline)
      next unless entry_in_range?(headline)

      # Create diet info container.
      diet_entry = {
	keyword:    headline.keyword,
	date:       diet_date(headline),
	properties: diet_properties(headline),
	entries:    diet_entries(headline_contents(headline))

      diet_days << diet_entry

    # Generate the file., 'w') do |file_out|

Tracking my diet with Emacs

I've been using Emacs with org-mode to track my diet since 2012. I've had some breaks along the way, the overall setup has stayed the same.

I use this system to track how much I weigh, as well as how many calories I'm consuming in a single day. There are plenty of apps and online services that provide this functionality, but I prefer to own my data in an open format I can use elsewhere.

Seeing as I work from home and always have an Emacs session open, it made sense for me to try to utilize Emacs in some way.

My set up uses the following Emacs and org-mode functionality:

  • Individual org-mode headlines for each day
  • org-mode properties for storing my weight
  • org-mode tables and spreadsheet formulas
  • org-capture for weighing in
  • Some elisp functions for adding individual food entries

Let's take a closer look at how all of this fits together.

Diet file setup

My diet file looks like this (with headings collapsed):

#+TITLE: Diet Tracker

* Daily Logs
** CAL-OUT Diet for day <2020-07-03 Fri>...
** CAL-OUT Diet for day <2020-07-02 Thu>...
** CAL-OUT Diet for day <2020-07-01 Wed>...
** CAL-OUT Diet for day <2020-06-30 Tue>...
** CAL-OUT Diet for day <2020-06-29 Mon>...
... And more

I tried to keep the file format as simple as possible. Each day has its own entry underneath the main "Daily Logs" header. The main "Daily Logs" header is used by org-capture to find where to place new items.

The top-level SEQ_TODO property is used to set the "done" state for each headline. I use three different types of headline:

For days where I haven't entered all of my diet information. There's usually only one of these open at a time.
For finished days where all data is final.
For days where I'm tracking weight but not calorie information. I use this for Sundays, days where I go out to eat, or when I have social events planned.

Daily entries

A daily entry looks something like this:

** CAL-OUT Diet for day <2019-10-05 Sat>
  :Weight:   167.3

| Timestamp              | Food                   | Calories | Quantity |   Total |
| [2019-10-05 Sat 08:42] | Cup of tea             |       54 |        1 |      54 |
| [2019-10-05 Sat 11:34] | Chocolate Chip Pancake |      2.7 |      202 |   545.4 |
| [2019-10-05 Sat 14:46] | Cup of tea             |       54 |        1 |      54 |
| [2019-10-05 Sat 14:46] | Vanilla yogurt raisins |     4.33 |       30 |   129.9 |
| [2019-10-05 Sat 17:45] | Beef meatballs         |     2.02 |      370 |   747.4 |
| [2019-10-05 Sat 17:45] | Fresh Pasta            |     1.31 |      306 |  400.86 |
| [2019-10-05 Sat 17:45] | Garlic bread           |     2.75 |       94 |   258.5 |
| [2019-10-05 Sat 19:58] | Cup of tea             |       54 |        1 |      54 |
| Total                  |                        |          |          | 2244.06 |
#+TBLFM: $5=$3*$4::@>$5=vsum(@2$5..@-I$5)

It's a fairly simple table that uses some org-mode magic for calculating totals. The "Calories" column is usually "calories per gram", but for some items it's "calories per item". Likewise, the quantity column either refers to the weight in grams or the number of items consumed.

The #+TBFLM: part underneath the table is an org-mode spreadsheet formula. It uses two formulae:

Sets column 5 (the "Totals" column) to Calories x Quantity. org-mode columns indexes start from 1 rather than 0.

Calculates the total amount of calories consumed during the day. It uses relative references so that it works no matter how many lines

I previously used $LR5 instead of @>$5 to reference the footer row, but this no longer worked after upgrading to org-mode 9.4.

The Spreadsheet section of the org-mode manual goes into detail on formulas. It took me a while to get the hang of, but it's a really powerful system.


I use an org-capture-template for my weigh-ins. I weigh myself every morning, depending on my schedule.

org-capture is bound to C-c o r, and then my weigh-in template is bound to w. So every day I run C-c o r w, enter my weight, then use C-c C-c to save it to my diet file. And that's it.

My capture template is below:

 '("w" "Weigh-in" entry
   (file+headline "~/self/" "Daily Logs")
   "* CAL-IN Diet for day %t
| Timestamp | Food | Calories | Quantity | Total |
| Total        |   |          |          |       |
#+TBLFM: $5=$3*$4::@>$5=vsum(@2$5..@-I$5)"
   :prepend t))

Adding new food entries

I have a couple of elisp functions I use for adding new data. The primary function is org-diet-copy, which is bound to C-c C-C.

org-diet-copy is used on a row of another table. It copies the food name, calorie amount, and quantity to the top table and replaces the timestamp with the current date and time.

The process of adding a new entry typically goes like this:

  • Hit C-s to search for the food I want to add. If I wanted to add a new entry for "french fries" I would probably do something like "C-s fren" to find the first "french fries" entry.
  • Hit C-c C-C to copy the entry to my active day.
  • Replace the quantity with whatever amount I ate. I try to keep the same portion size for breakfast and snacks, so this isn't always necessary.
  • Run M-x org-table-recalculate to update the table.

It's a simple system, but it works well enough. Eventually I may add function that prompts for a food and quantity, and then automatically fills in the calorie amount.

All of the functions I use are below:

(defun org-diet-move-today ()
  "Move to today's entry in the org-diet file."

  ;; Move to first heading (Daily Logs) and expand.
  (outline-next-visible-heading 1)
  (outline-next-visible-heading 1))

(defun org-diet-move-last-entry ()
  "Move to the last entry in the current diet table."

  (search-forward "#+TBLFM")

  ;; Move to start of final line.
  (previous-line 1)
  (previous-line 1)
  (move-beginning-of-line 1))

(defun org-diet-copy ()
  "Copy the current table line to today.

Copies the current table line and moves it to the bottom of
today's diet table.  Changes the timestamp to the current time
and day."

  (let ((diet-line (thing-at-point 'line t)))
    ;; Jump to today & last line.

    ;; Insert the copied line.
    (insert diet-line)

    ;; Move back to last line.
    (forward-line -1)

    ;; Replace the date.
    (search-forward "| ")
    (zap-to-char 1 93)
    (insert (format-time-string "[%Y-%m-%d %a %H:%M]" (current-time)))

    ;; Update the table.
    (forward-line 2)

    ;; Move to inserted line.
    (forward-line -2)))

;; Bind copying to `C-c C-C`.
(global-set-key "\C-cC" #'org-diet-copy)

In part two I'll cover the system I use to extract data from the org-mode file. It's not pretty.