Breaking Down the Zsh Notes Function

Earlier, I discussed my need to have method to jot down notes quickly and comfortably on a computer. I wanted the setup to be as painless as possible while also giving me the flexibility I desired to extend the note-taking method.

Being a lightweight user of zsh - not leveraging many plugins or additional functionality - I decided to explore writing a zsh function to be exposed in my shell. This function’s goal was to manage my notes, providing functionality to list existing notes, create new notes, and delete old notes. At the end of the day, I arrived at a 122 line zsh function as seen at the bottom of this post.

Design

Before we break apart the function, let me delve into the design at a high level. When building the functionality to manage notes, I wanted it to do a couple of things:

  1. Create new notes using their names (e.g. notes create my-new-note)
  2. Edit existing notes using their names (e.g. notes edit my-new-note)
  3. List existing notes that have been created (e.g. notes list)
  4. Delete existing notes using their names (e.g. notes delete my-new-note)

In my head, each of these actions represents a different mode of operation for the zsh function. To that end, I broke out the zsh function into notes [MODE] [NAME], where the mode is provided to the zsh function as the first argument and the name of the note (or other arguments) is provided as the second argument. In other CLI programs, I believe this is called a subcommand. If I wanted to create a new note, my initial thought was to type notes create my-new-note where create served as the mode, or subcommand, and my-new-note represented the name of the note.

Changes to Design

If you look closely at the notes function as we progress through it, you’ll notice that there isn’t any mention of create. At the end of the day, I found supporting two different names for the same action not useful. I set up my defaults such that notes edit my-new-note creates a new note and opens it for editing. This becomes even smoother with the default mode being file editing such that notes my-new-note creates and opens the file for editing as well.

Implementation

Defining Modes of Operation

local MODE_EDIT="edit"
local MODE_REMOVE="remove"
local MODE_LIST="list"
local MODE_HELP="help"
local MODE_DEFAULT="$MODE_LIST"

local mode="$MODE_EDIT"

The first section of the zsh function defines the different modes of operation. In a programming language like C, these could have been #define MODE_EDIT edit or something similar. I wanted a way to mark the type of operation that was going to be performed. This would be contained in the mode variable and would be set using one of the constants above it.

Specifying Aliases for Modes

local -a COMMAND_REMOVE
COMMAND_REMOVE=(delete remove del rm d r)
local -a COMMAND_LIST
COMMAND_LIST=(list ls l)
local -a COMMAND_EDIT
COMMAND_EDIT=(edit e)
local -a COMMAND_HELP
COMMAND_HELP=(help h)

Typing takes a lot of effort, so I wanted to provide aliases to different modes of operation to reduce how much I type. E.g. notes delete my-new-note could be rewritten as notes d my-new-note. Rather than having a giant series or clauses in my conditionals, I decided to create arrays of words that I could use to represent a mode of operation. This makes it easier for me to add or remove aliases for each mode and centralizes them at the top of the function.

Acquiring Date Information

local date="$(date +'%Y-%m-%d')"
local human_date="$(date +'%A, %B %d, %Y (%Y-%m-%d)')"

This is used later to populate part of a new note with the current date. These lines leverage the external date shell function to acquire both a date string for use as a file name and a second date string to be more human-readable.

  • The date variable is filled with a string in the form of “year-month-day” like “2018-06-30”
  • The human_date variable is filled with a readable string like “Sunday, July 08, 2018 (2018-07-08)”

Creating the Base Path

local base_path="$HOME/.notes"
mkdir -p "$base_path"

All of my notes live in a flat structure, being placed in a directory specified by the variable base_path. For my case, I decided to have the directory be a hidden one within my home directory. This makes it easily accessible if I need to copy content out of it - like with blog posts I’m moving to my site’s repo - while also staying out of the way of my normal shell navigation.

This code initializes the base path upon the notes function being invoked, ensuring that any directory along the path is created. In this case, that’s making sure that the .notes directory is created within my home directory, but it also means it supports a deeper base path.

Setting Up State

local DEFAULT_NOTE_FILE="default"
local DEFAULT_NOTE_NAME="default"
local DEFAULT_NOTE_TITLE="General Notes"
local note_file=""
local note_title=""
local note_name=""
local note_path=""

local cmd="$1"

The final portion of setup code is to set a couple of default values for properties the function cares about - note name, file name, and title used at the top of the note - as well as defining the variables used to keep track of relevant state.

This also includes grabbing the first argument of the function and storing it as the cmd variable for use in determining the mode of operation. E.g. notes edit my-new-note would set cmd to edit.

Determine Mode of Operation

# Process specific modes if provided
if [[ ${COMMAND_REMOVE[(ie)$cmd]} -le ${#COMMAND_REMOVE} ]]; then
  mode="$MODE_REMOVE"
  shift
  cmd="$1"
elif [[ ${COMMAND_EDIT[(ie)$cmd]} -le ${#COMMAND_EDIT} ]]; then
  mode="$MODE_EDIT"
  shift
  cmd="$1"
elif [[ ${COMMAND_LIST[(ie)$cmd]} -le ${#COMMAND_LIST} ]]; then
  mode="$MODE_LIST"
  shift
  cmd="$1"
elif [[ ${COMMAND_HELP[(ie)$cmd]} -le ${#COMMAND_HELP} ]]; then
  mode="$MODE_HELP"
  shift
  cmd="$1"
elif [ "$cmd" = "" ]; then
  mode="$MODE_DEFAULT"
fi

Now comes the conditional code used to determine what mode of operation the code will be in for the remainder of the function. Currently, this is a series of if statements checking the command (first argument) against each of the arrays of aliases specified earlier.

${COMMAND_REMOVE[(ie)$cmd]} -le ${#COMMAND_REMOVE}

This uses some less-than-obvious subscript flags to find the first match within the array and return its index (e.g. ${COMMAND_REMOVE[(ie)$cmd]}) and then check if that index is less than or equal to the last index in the array (e.g. ${#COMMAND_REMOVE}). The indices start at 1 instead of 0, and if there is no match found, the index after the last value in the array is returned, which would be greater than the last index in the array, thereby returning false in the condition.

shift
cmd="$1"

For known commands, I also pop off the first argument and update the cmd variable to the next in the list. In the case of notes edit date, this would update cmd to be date after updating the mode to be editing a file. This makes it easier to work with a subcommand (like date) later.

In this series of conditionals, I’ve also introduced the concept of default mode that is used when no arguments are provided. The constant MODE_DEFAULT, which is the list mode, is used as the mode if nothing is provided. This means running notes by itself will list all existing notes.

Finally, there’s an implicit mode set when the first argument is provided, but is not a recognized mode. E.g. notes my-new-note. For this scenario, the existing value in mode will be used, which is the edit mode. This makes it so I don’t have to type notes edit my-new-note or any variant. Instead, I can just type the function and the name of the note to create or resume and edit a note.

Setting the Note State

# If date provided, will open notes for current date
if [ "$cmd" = "date" ]; then
  note_name="date"
  note_title="Notes for $human_date"
  note_file="$date.md"

# If nothing provided, will open default note
elif [ "$cmd" = "" ]; then
  note_name="$DEFAULT_NOTE_NAME"
  note_title="$DEFAULT_NOTE_TITLE"
  note_file="$DEFAULT_NOTE_FILE.md"

# Otherwise, anything else will be treated as a file for the command
# e.g. 'notes js' will open js.md note file
else
  note_name="$cmd"
  note_title="Notes for $cmd"
  note_file="$cmd.md"
fi

Here is where the state of the notes function gets filled in. The most common state updates come from providing a note name (e.g. notes edit my-new-note) or using a default note name (e.g. notes edit). There is one additional option, which is providing the specific argument of date. In this scenario, the function fills in the title using our human_date from earlier and the name of the file will be the compact date variable (e.g. 2018-06-30). This can be injected through commands like notes date, notes edit date, and even notes remove date.

Creating the Note File if Missing

# Update full path
note_path="$base_path/$note_file"

if [ "$mode" = "$MODE_EDIT" ] && [ ! -f "$note_path" ]; then
  builtin echo "# $note_title" >> "$note_path"
  builtin echo "" >> "$note_path"
fi

Now that the state of the notes function has been updated with the appropriate title and file to edit, we build up the full path to the file and check if we’re in the edit mode. If so and the file doesn’t exist, the function will create a new file with markdown syntax for a top-level header. For instance, notes my-new-note will create a new file (named my-new-note.md) like below:

# Notes for my-new-note

This also applies to the date syntax, where notes date for June 30th, 2018 would create a new file named 2018-06-30.md and contents like below:

# Notes for Sunday, June 30, 2018 (2018-06-30)

Performing the Mode Operation

The finally section of code is a series of conditionals to determine which mode the function is in (create/edit, remove, list, or help) and then perform the associated operation.

Opening a File for Editing (or Creation)

if [ "$mode" = "$MODE_EDIT" ]; then
  $EDITOR "$note_path"

This is as simple as it gets. The function relies on the EDITOR variable being appropriately set. You do have your EDITOR variable set to something like nvim, don’t you? The function passes to the shell variable the full path to the file to edit. In my case, a call like notes my-new-note turns into nvim /Users/senk/.notes/my-new-note.md on Mac OS X.

Removing a File

elif [ "$mode" = "$MODE_REMOVE" ]; then
  if [ ! -f "$note_path" ]; then
    builtin echo "No note exists for $note_name!"
  else
    rm -i "$note_path"
  fi

Removing a file has a single conditional to check if the note being removed does not exist. This helps me know if I mistyped a note so I can correct myself. Additionally, removing a file is done interactively, so I can double-check that I want to remove the note in question. This is especially important given that my notes are not versioned, which is discussed during my concluding thoughts.

Listing All Note Files

elif [ "$mode" = "$MODE_LIST" ]; then
  ls "$base_path"

The list mode takes no arguments and purely echos out the files within the notes directory. This means that the markdown extension of .md that is added to the files will show up. One thought of mine is to pipe this through some other tool to remove the extensions and better present the notes. Another is to provide a means of filtering notes in some way.

Displaying Help Text

elif [ "$mode" = "$MODE_HELP" ]; then
  builtin echo "Usage: notes [<command>] [<name>]

Commands
= $MODE_EDIT =
  Aliases: $COMMAND_EDIT
  Arguments:
    - name: Name of the note to edit without the .md extension

..."
fi

The last mode of operation is the help printout. Given that my list of aliases may change over time, I wanted to be able to print out how to enable each mode. Given that the function is already aware of the aliases via variables like COMMAND_EDIT, we leverage those variables in a string that is printed out to represent the help text.

Concluding Thoughts

I’ve used this function a fair amount since writing it, especially to create and edit notes, ranging from writing drafts of new blog posts to taking notes from meetings at work. Being a native function in zsh, it’s been incredibly portable, enabling me to quickly get set up to take notes on my personal and work machines.

In the future, I’ve thought about converting the notes directory into a repository, enabling versioning of notes and synchronicity across different machines. Maybe through an operation like notes sync, I could do a git pull followed by a git push or some other form of version control operation.

Additionally, given that I use my notes as a drafting organization for my blog, I’ve thought about adding support to configure the function toward copying notes to other directories so I can transition content to Hugo.

No matter what I do, having a quick way to jot down notes on my computer has helped me stay more organized both personally - this blog - and at work. Regardless of what you choose, lowering the barrier to note taking is a huge win for productivity and I encourage everyone to find their way.

Source Code

There is no license on this code. Consider it public domain to do with as you please.

notes() {
  local MODE_EDIT="edit"
  local MODE_REMOVE="remove"
  local MODE_LIST="list"
  local MODE_HELP="help"
  local MODE_DEFAULT="$MODE_LIST"

  local mode="$MODE_EDIT"

  local -a COMMAND_REMOVE
  COMMAND_REMOVE=(delete remove del rm d r)
  local -a COMMAND_LIST
  COMMAND_LIST=(list ls l)
  local -a COMMAND_EDIT
  COMMAND_EDIT=(edit e)
  local -a COMMAND_HELP
  COMMAND_HELP=(help h)

  local date="$(date +'%Y-%m-%d')"
  local human_date="$(date +'%A, %B %d, %Y (%Y-%m-%d)')"

  local base_path="$HOME/.notes"
  mkdir -p "$base_path"

  local DEFAULT_NOTE_FILE="default"
  local DEFAULT_NOTE_NAME="default"
  local DEFAULT_NOTE_TITLE="General Notes"
  local note_file=""
  local note_title=""
  local note_name=""
  local note_path=""

  local cmd="$1"

  # Process specific modes if provided
  if [[ ${COMMAND_REMOVE[(ie)$cmd]} -le ${#COMMAND_REMOVE} ]]; then
    mode="$MODE_REMOVE"
    shift
    cmd="$1"
  elif [[ ${COMMAND_EDIT[(ie)$cmd]} -le ${#COMMAND_EDIT} ]]; then
    mode="$MODE_EDIT"
    shift
    cmd="$1"
  elif [[ ${COMMAND_LIST[(ie)$cmd]} -le ${#COMMAND_LIST} ]]; then
    mode="$MODE_LIST"
    shift
    cmd="$1"
  elif [[ ${COMMAND_HELP[(ie)$cmd]} -le ${#COMMAND_HELP} ]]; then
    mode="$MODE_HELP"
    shift
    cmd="$1"
  elif [ "$cmd" = "" ]; then
    mode="$MODE_DEFAULT"
  fi

  # If date provided, will open notes for current date
  if [ "$cmd" = "date" ]; then
    note_name="date"
    note_title="Notes for $human_date"
    note_file="$date.md"

  # If nothing provided, will open default note
  elif [ "$cmd" = "" ]; then
    note_name="$DEFAULT_NOTE_NAME"
    note_title="$DEFAULT_NOTE_TITLE"
    note_file="$DEFAULT_NOTE_FILE.md"

  # Otherwise, anything else will be treated as a file for the command
  # e.g. 'notes js' will open js.md note file
  else
    note_name="$cmd"
    note_title="Notes for $cmd"
    note_file="$cmd.md"
  fi

  # Update full path
  note_path="$base_path/$note_file"

  if [ "$mode" = "$MODE_EDIT" ] && [ ! -f "$note_path" ]; then
    builtin echo "# $note_title" >> "$note_path"
    builtin echo "" >> "$note_path"
  fi

  if [ "$mode" = "$MODE_EDIT" ]; then
    $EDITOR "$note_path"
  elif [ "$mode" = "$MODE_REMOVE" ]; then
    if [ ! -f "$note_path" ]; then
      builtin echo "No note exists for $note_name!"
    else
      rm -i "$note_path"
    fi
  elif [ "$mode" = "$MODE_LIST" ]; then
    ls "$base_path"
  elif [ "$mode" = "$MODE_HELP" ]; then
    builtin echo "Usage: notes [<command>] [<name>]

Commands
= $MODE_EDIT =
  Aliases: $COMMAND_EDIT
  Arguments:
    - name: Name of the note to edit without the .md extension

= $MODE_REMOVE =
  Aliases: $COMMAND_REMOVE
  Arguments:
    - name: Name of the note to remove without the .md extension

= $MODE_LIST =
  Aliases: $COMMAND_LIST
  Arguments:

= $MODE_HELP =
  Aliases $COMMAND_HELP
  Arguments:

By default, if no command is provided and no name is provided, all notes will \
be listed.

By default, if no command is provided and a name is provided, the note with the \
provided name will be opened (or created if does not exist) for editing."
  fi
}