Boston Lee

CLI notes management: Where is the boundary?


As a small project, I have written and rewritten my notes management suite a number of times. I have tried POSIX shell scripts, Python scripts, some uncompleted Rust and Go, and finally a proper Python command line application. One thing has been constant across all of these attempts, however: What functionality should be left as an exercise to the reader?

I find it especially tempting to strip out functionality in this application, specifically because my notes are all simple text files. It seems only rational that you could simply hand a file path off to a user, and let them manipulate the files with their desired shell. Perhaps someone else will have a more principled answer than I, but I would like to explore two approaches to the problem that I believe both have some merit. Without spoiling too much, however, I think one of the approaches is only tenable in the case of my very simple application.

Approach #1: Full note management

For some background, my notes are all stored in a directory on my local drive: ~/notes. Each note is actually a timestamped directory (its “ID”), in the form of YYYYMMDDHHMMSS/, with a markdown file in that directory with a predefined name. In my case, I chose README.md, but it could be anything for the purposes of this walkthrough.

A reasonable approach would be to abstract the user of a notes CLI application entirely away from the physical storage of the notes, and have the primary interface be CRUD-like:

note create
note edit <id>
note delete <id>
note list
note search <terms>

This is how my notes CLI functions at the moment. And it works well for my purposes. But I got around to extending it:

fnote() {
  if [ $# -eq 0 ]; then
    note_entries=$(note list --plain)
  else
    note_entries=$(note search --plain "$@")
  fi
  if [ -z "$note_entries" ]; then
    echo "No entries found!"
    return 1
  fi
  if [ "$(echo "$note_entries" | wc -l)" -eq 1 ]; then
    note edit "$(echo "$note_entries" | cut -f 1 -d ':')"
  else
    note edit "$(echo "$note_entries" | fzf | cut -f 1 -d ':')"
  fi
}

The above Bash function uses the output of my note search or list---depending on whether I provide search arguments---to construct a basic TUI search over the notes, and open one that I select. Simple enough, but critically it depends on the arbitrary format of note list --plain, which begins with <note id>: ... for each of the found notes.

That’s when an insidious idea nagged at me for a couple of days. The current implementation leaves any sort of UI (really, TUI) to the user to implement. Why not go further?

Approach #2: The minimum viable notes management system

Because my notes are files, the majority of the interface outlined above is redundant:

| note edit | "$EDITOR" <note file> | | note delete|rm | |note search|grep -R ` |

I think note create and note list are not immediately superseded by simple shell commands, but could be easily written as shell scripts.

To me, the minimum viable note system seems to have the following components:

  1. Some command to create a new note, and echo its filepath on creation: note-create.
  2. Some command to list all existing note paths, or a single path: note-list [<note id>].

Certainly, this is simpler than a Python application. We could write these commands in a couple of lines of shell.

For create, we just need to encode how the ID should look:

note-create() {
    new_id=$(date -u +"%Y%m%d%H%M%S")
    note_dir="${NOTES_DIR}/${new_id}/"
    mkdir -p "${note_dir}"
    note_file="${note_dir}/README.md"
    touch "$note_file"
    echo "$note_file"
}

And for list, we just need to know the directory where the notes are stored:

note-list() {
    if [ "$#" -gt 1 ]; then
        echo "usage: $0 [<note id>]"
        exit 1
    fi
    if [ "$#" -eq 1 ]; then
        find "$NOTES_DIR" -type f | grep "$1"
        exit 0
    fi 
    find "$NOTES_DIR" -type f
}

With this implementation, we only deal in file names. Whereas the note list command in the earlier version may have provided helpful, parsed output, this version simply provides terse file names. But this has a distinct advantage: Users can do what they wish with those files.

I doubt I could write a search as fast as grep:

note-search() {
    note-list | xargs grep "$1"
}

Removing notes can also have extra interactivity built in, without having to handle user input:

note-delete() {
    note-list "$1" | xargs -o rm -i
}

Where this implementation starts to fall flat is in the parsing of the files for things like tags. Luckily, there are implementions out there to handle that parsing. So, if we wanted to display a certain aspect of a note to a user, we could simply parse it to a data interchange format like JSON:

fnote-new() {
    # Not fleshed out, you get the picture
    note-list "$1" | xargs <some parser> | jq '.<field>' | fzf ...
}

And so, the “business logic” of the notes directory and ID generation can be siloed off from the infinite possible actions a user might want to take on those files.

Comparison: Pros and Cons

Obviously, the more minimal implementation wins out on extensibility. I even got myself excited thinking about decoupling my notes from a specific markdown parsing Python library, and just letting a UNIX-like utility handle it.

However, speaking from my own limited experience, the extensibility comes at the cost of consistency. You can imagine a user extending the minimal notes app and having to tie their shell scripts to different invocations of jq '.<field>' for all of the different pieces of parsed data that they want to pull out.

Meanwhile, a Python (or Rust, etc.) implementation can have a consistent Note class that encapsulates the data and operations required for a single note. Although that seems simple, it is valuable to keep in mind in this case, because the alternative data organization (ie, shell script messiness) comes with the enormous upside of using grep or ripgrep for search.

The temptation to fall back on more robust search functionality for computationally-intensive notes management tasks is there. Perhaps when I actually take more notes than I do, I will write up and compare a shell scripted version. For now, a hastily-written search function in Python works well enough.

Ultimately, I think shielding the user entirely from the filesystem layout and markdown flavor of the notes (using a flexible parser for metadata) is worthwhile. The fact that a user can express an edit as note edit "$(...)" means that the CLI application can still be composed with itself, without ever referencing a file.

I believe the CRUD-like implementation is superior for reasons of maintainability, while lacking in extensibility. If I want interactive deletion, I need to add that feature myself. That is a drawback, but having lived with messy shell scripts for this task, it is a drawback that I find worthwhile.