#!/usr/bin/env bash
-
+#
+# dmenu-notes — dmenu front end for a front-matter-driven markdown notes tree.
+#
# Tucker Johnson (git.newer.systems) (tucker@newer.systems)
+#
+# Expects a tree along these lines:
+#
+# $NOTES/
+# teaching/classes/<course>/<term>/
+# teaching/lessons/<lastname-firstname>/
+# compositions/<piece>/
+# studies/ projects/ inbox/ ref/ meta/ archive/
+# calendar/ (institution-split .rem tree; see $NOTES_CALENDAR)
+# check-out.md todo.txt
+#
+# Paths are canonical; front matter is authoritative. This script writes only
+# the keys a human must decide, then hands the file to notes-lint --fix, which
+# fills in everything the path (or another key) already determines. The
+# derivation rules live in exactly one place: $NOTES/meta/schema.md, as
+# implemented by $NOTES/meta/notes-lint.
+
+set -o pipefail
+
+notes="${NOTES:-$HOME/Notes}"
+notes="${notes%/}"
+calendar="${NOTES_CALENDAR:-$notes/calendar}"
+calendar="${calendar%/}"
+editor="${EDITOR:-vi}"
+lint="${NOTES_LINT:-$notes/meta/notes-lint}"
+gitea="${NOTES_GITEA:-http://elektra:3000/tuckerj}"
+termemu="${TERMINAL:-st}"
+
+# Directories skipped when listing recent notes.
+prune_names=(.git .obsidian node_modules)
+prune_paths=("$notes/archive")
+
+# ---------------------------------------------------------------- helpers ---
+
+die() { printf '%s\n' "$*" >&2; exit 1; }
+
+menu() { dmenu -l 20 -i -p "$1"; }
+ask() { dmenu -p "$1" </dev/null; } # free text; returns what you typed
+
+open_file() {
+ mkdir -p "$(dirname "$1")" || return 1
+ setsid -f "$termemu" -e "$editor" "$1" >/dev/null 2>&1
+}
+
+open_at_end() {
+ mkdir -p "$(dirname "$1")" || return 1
+ set -- "$editor" "$1" # $1 = editor word(s), $2 = file
+ case "${editor%% *}" in
+ *vim|*vi|*nvim) setsid -f $editor + "$2" >/dev/null 2>&1 ;;
+ *) setsid -f $editor "$2" >/dev/null 2>&1 ;;
+ esac
+}
+
+# slug "Jane Smith — Viola" -> jane-smith-viola
+slug() {
+ printf '%s' "$1" \
+ | tr 'A-Z' 'a-z' \
+ | tr -cs 'a-z0-9' '-' \
+ | sed 's/^-*//; s/-*$//'
+}
+
+# Current term slug, e.g. 2026-spring
+current_term() {
+ local y m
+ y=$(date +%Y); m=$(date +%-m)
+ if [ "$m" -ge 8 ]; then printf '%s-fall' "$y"
+ elif [ "$m" -le 5 ]; then printf '%s-spring' "$y"
+ else printf '%s-summer' "$y"; fi
+}
+
+find_prune_expr() {
+ local first=1 n p
+ printf '('
+ for n in "${prune_names[@]}"; do
+ [ $first -eq 1 ] || printf ' -o'
+ printf ' -name %s' "$n"; first=0
+ done
+ for p in "${prune_paths[@]}"; do
+ printf ' -o -path %s' "$p"
+ done
+ printf ' )'
+}
+
+# All notes, newest first, paths relative to $notes.
+list_notes() {
+ # shellcheck disable=SC2046
+ find "$notes" $(find_prune_expr) -prune -o \
+ -type f \( -name '*.md' -o -name '*.rem' -o -name '*.txt' \) \
+ -printf '%T@ %P\n' 2>/dev/null | sort -nr | cut -d' ' -f2-
+}
+
+# All directories under a base, relative, with "." first so you can pick the
+# base itself. Typing a path that does not exist creates it.
+pickdir() {
+ local base="$1" label="${2:-Directory}" rel
+ mkdir -p "$base" || return 1
+ rel="$( { printf '.\n'
+ cd "$base" && find . -mindepth 1 -type d -not -path '*/.git*' \
+ -printf '%P\n' 2>/dev/null | sort
+ } | menu "$label (type a new one to create): " )" || return 1
+ [ -z "$rel" ] && return 1
+ rel="${rel#./}"
+ [ "$rel" = "." ] && rel=""
+ mkdir -p "$base/$rel" || return 1
+ printf '%s' "${base}${rel:+/$rel}"
+}
+
+# ----------------------------------------------------------- front matter ---
+
+# Emit a front matter block on stdout: fm <type> [extra yaml lines...]
+#
+# Authored keys only. No id, no composition/course/term/people, and no
+# md2pdfpy — the first group is derived by notes-lint, and md2pdfpy belongs
+# only on documents you actually print, naming a template that exists.
+fm() {
+ local type="$1"; shift
+ printf -- '---\n'
+ printf 'type: %s\n' "$type"
+ local line
+ for line in "$@"; do printf '%s\n' "$line"; done
+ printf -- '---\n\n'
+}
+
+# Write front matter only if the file is new, then let the linter fill in the
+# derived keys. Running logs get appended to for years; never staple a second
+# block onto the top of one.
+seed() {
+ local file="$1"; shift
+ [ -e "$file" ] && return 0
+ mkdir -p "$(dirname "$file")" || return 1
+ fm "$@" > "$file"
+ [ -x "$lint" ] && "$lint" --root "$notes" --fix "$file" >/dev/null 2>&1
+ return 0
+}
+
+# Closed vocabulary; see meta/schema.md.
+pick_institution() {
+ printf '%s\n' eastman ecms fredonia private | menu 'Institution: '
+}
+
+# ------------------------------------------------------------ note makers ---
+
+new_lesson() {
+ local dir student file
+ dir="$(pickdir "$notes/teaching/lessons" 'Student')" || return 0
+ student="$(basename "$dir")"
+ [ "$student" = "lessons" ] && { student="$(slug "$(ask 'New student (lastname-firstname): ')")"
+ [ -z "$student" ] && return 0
+ dir="$notes/teaching/lessons/$student"; }
+
+ # Durable profile, created once per student. institution is derived from
+ # enrollment, so only the history is ever authored.
+ if [ ! -e "$dir/index.md" ]; then
+ local inst; inst="$(pick_institution)" || return 0
+ [ -z "$inst" ] && return 0
+ seed "$dir/index.md" student \
+ "discipline: composition" \
+ "aliases: []" \
+ "focus: []" \
+ "excluded: []" \
+ "tools: []" \
+ "enrollment:" \
+ " - institution: $inst" \
+ " from: $(date +%Y-%m)" \
+ " to: null"
+ fi
+
+ file="$dir/log.md"
+ seed "$file" log
+
+ printf '\n## %s \n\n' "$(date +%F)" >> "$file"
+ open_at_end "$file"
+}
+
+new_class() {
+ local dir course term file
+ dir="$(pickdir "$notes/teaching/classes" 'Course')" || return 0
+ course="$(basename "$dir")"
+ [ "$course" = "classes" ] && { course="$(slug "$(ask 'New course: ')")"
+ [ -z "$course" ] && return 0
+ dir="$notes/teaching/classes/$course"; }
+
+ term="$(ask "Term [$(current_term)]: ")" || return 0
+ : "${term:=$(current_term)}"
+ term="$(slug "$term")"
+
+ local name; name="$(ask 'Note name (blank for today): ')" || return 0
+ : "${name:=$(date +%F)}"
+ name="$(slug "$name")"
+
+ file="$dir/$term/$name.md"
+ if [ ! -e "$file" ]; then
+ local type inst
+ case "$name" in
+ syllabus) type=syllabus ;;
+ *) type=note ;;
+ esac
+ inst="$(pick_institution)" || return 0
+ [ -z "$inst" ] && return 0
+ seed "$file" "$type" "institution: [$inst]"
+ fi
+ open_file "$file"
+}
+
+# Pieces are entities: the directory gets an index, leaves get a document type.
+new_composition() {
+ local dir piece file name type
+ dir="$(pickdir "$notes/compositions" 'Piece')" || return 0
+ piece="$(basename "$dir")"
+ [ "$piece" = "compositions" ] && { piece="$(slug "$(ask 'New piece: ')")"
+ [ -z "$piece" ] && return 0
+ dir="$notes/compositions/$piece"; }
+
+ seed "$dir/index.md" composition "status: active"
+
+ name="$(ask 'Note name (blank for the piece index): ')" || return 0
+ if [ -z "$name" ]; then
+ file="$dir/index.md"
+ else
+ name="$(slug "$name")"
+ file="$dir/$name.md"
+ if [ ! -e "$file" ]; then
+ type="$(printf '%s\n' note sketch analysis journal | menu 'Type: ')" \
+ || return 0
+ : "${type:=note}"
+ seed "$file" "$type"
+ fi
+ fi
+ open_file "$file"
+}
+
+# Courses live directly under studies/, so the two reserved names are filtered
+# out here; see "Reserved names under studies/" in meta/schema.md.
+pick_course() {
+ local base="$notes/studies" rel
+ mkdir -p "$base" || return 1
+ rel="$( { cd "$base" && find . -mindepth 1 -maxdepth 1 -type d -printf '%P\n' \
+ 2>/dev/null | grep -vxE 'dissertation|independent' | sort
+ } | menu 'Course (type a new one to create): ' )" || return 1
+ [ -z "$rel" ] && return 1
+ printf '%s' "$(slug "$rel")"
+}
+
+# Coursework: studies/<course>/<term>/<name>.md
+new_study_course() {
+ local course term name type inst file
+ course="$(pick_course)" || return 0
+ [ -z "$course" ] && return 0
+
+ term="$(ask "Term [$(current_term)]: ")" || return 0
+ : "${term:=$(current_term)}"
+ term="$(slug "$term")"
+
+ name="$(ask 'Name (blank for today): ')" || return 0
+ : "${name:=$(date +%F)}"
+ name="$(slug "$name")"
+
+ file="$notes/studies/$course/$term/$name.md"
+ if [ ! -e "$file" ]; then
+ type="$(printf '%s\n' note reading paper research assignment \
+ presentation analysis | menu 'Type: ')" || return 0
+ : "${type:=note}"
+ inst="$(pick_institution)" || return 0
+ [ -z "$inst" ] && return 0
+ seed "$file" "$type" "institution: [$inst]" "date: $(date +%F)"
+ fi
+ open_file "$file"
+}
+
+# Long-form study: a dissertation or an independent-study topic. Both are
+# project entities with an index and freely-named leaves.
+new_study_long() {
+ local dir name type inst file
+ dir="$1"
+ if [ ! -e "$dir/index.md" ]; then
+ inst="$(pick_institution)" || return 0
+ [ -z "$inst" ] && return 0
+ seed "$dir/index.md" project "status: active" "institution: [$inst]"
+ fi
+
+ name="$(ask 'Name (blank for the index): ')" || return 0
+ if [ -z "$name" ]; then
+ file="$dir/index.md"
+ else
+ name="$(slug "$name")"
+ file="$dir/$name.md"
+ if [ ! -e "$file" ]; then
+ type="$(printf '%s\n' note research paper analysis sketch journal \
+ | menu 'Type: ')" || return 0
+ : "${type:=note}"
+ seed "$file" "$type"
+ fi
+ fi
+ open_file "$file"
+}
+
+new_study() {
+ local kind topic
+ kind="$(printf '%s\n' course dissertation independent | menu 'Study: ')" \
+ || return 0
+ case "$kind" in
+ course) new_study_course ;;
+ dissertation) new_study_long "$notes/studies/dissertation" ;;
+ independent)
+ topic="$(pickdir "$notes/studies/independent" 'Topic')" || return 0
+ [ "$(basename "$topic")" = independent ] && {
+ topic="$(slug "$(ask 'New topic: ')")"
+ [ -z "$topic" ] && return 0
+ topic="$notes/studies/independent/$topic"; }
+ new_study_long "$topic" ;;
+ *) return 0 ;;
+ esac
+}
+
+# Projects document something that lives outside the notes tree. tracks points
+# at the canonical remote, never a local path — these notes sync across
+# machines, so ~/Documents/... is wrong everywhere but one. See meta/schema.md.
+new_project() {
+ local dir project file name type tracks
+ dir="$(pickdir "$notes/projects" 'Project')" || return 0
+ project="$(basename "$dir")"
+ [ "$project" = "projects" ] && { project="$(slug "$(ask 'New project: ')")"
+ [ -z "$project" ] && return 0
+ dir="$notes/projects/$project"; }
+
+ if [ ! -e "$dir/index.md" ]; then
+ # The Gitea convention is offered first; anything else can be typed,
+ # and "(none)" suits a project that documents hardware, not a repo.
+ tracks="$(printf '%s\n' "$gitea/$project.git" '(none)' | menu 'Tracks: ')" \
+ || return 0
+ [ "$tracks" = '(none)' ] && tracks=""
+ if [ -n "$tracks" ]; then
+ seed "$dir/index.md" project "status: active" "tracks: [$tracks]"
+ else
+ seed "$dir/index.md" project "status: active"
+ fi
+ fi
+
+ name="$(ask 'Note name (blank for the project index): ')" || return 0
+ if [ -z "$name" ]; then
+ file="$dir/index.md"
+ else
+ name="$(slug "$name")"
+ file="$dir/$name.md"
+ if [ ! -e "$file" ]; then
+ type="$(printf '%s\n' note sketch analysis journal | menu 'Type: ')" \
+ || return 0
+ : "${type:=note}"
+ seed "$file" "$type"
+ fi
+ fi
+ open_file "$file"
+}
+
+# inbox/ and ref/ are deliberately outside the schema (meta/schema.md), so
+# these are created bare — no front matter at all.
+new_plain() {
+ local base="$1" name file
+ name="$(ask 'Name (blank for today): ')" || return 0
+ : "${name:=$(date +%F)}"
+ file="$notes/$base/$(slug "$name").md"
+ mkdir -p "$(dirname "$file")" || return 1
+ [ -e "$file" ] || : > "$file"
+ open_file "$file"
+}
+
+new_calendar() {
+ local dir name file
+ dir="$(pickdir "$calendar" 'Calendar directory')" || return 0
+ name="$(slug "$(ask 'Name: ')")" || return 0
+ [ -z "$name" ] && return 0
+ file="$dir/$name.rem"
+ if [ ! -e "$file" ]; then
+ mkdir -p "$dir"
+ printf '# %s\n# included by %s\n\n' "$name" "$calendar/calendar.rem" > "$file"
+ fi
+ open_file "$file"
+}
+
+newnote() {
+ local type
+ type="$(printf '%s\n' lesson class composition study project inbox ref calendar \
+ | menu 'Note type: ')" || return 0
+ case "$type" in
+ lesson) new_lesson ;;
+ class) new_class ;;
+ composition) new_composition ;;
+ study) new_study ;;
+ project) new_project ;;
+ inbox) new_plain inbox ;;
+ ref) new_plain ref ;;
+ calendar) new_calendar ;;
+ *) return 0 ;;
+ esac
+}
+
+# ------------------------------------------------- front matter query mode ---
+
+# Files whose front matter has <key> matching <regex>. Handles both inline
+# values and " - item" continuation lists. No nextfile/gawk extensions used.
+fm_files() {
+ local key="$1" val="$2"
+ # shellcheck disable=SC2046
+ find "$notes" $(find_prune_expr) -prune -o -type f -name '*.md' -print0 2>/dev/null \
+ | xargs -0 -r awk -v K="$key" -v V="$val" '
+ FNR == 1 { infm = 0; started = 0; hit = 0; key = "" }
+ /^---[[:space:]]*$/ {
+ if (!started) { started = 1; infm = 1; next }
+ if (infm) { infm = 0; if (hit) print FILENAME; next }
+ }
+ infm {
+ if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*:/) {
+ key = $0; sub(/:.*/, "", key)
+ rest = $0; sub(/^[^:]*:[[:space:]]*/, "", rest)
+ } else {
+ rest = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", rest)
+ }
+ gsub(/^[\[ ]+|[\] ]+$/, "", rest)
+ if (key == K && rest != "" && rest ~ V) hit = 1
+ }
+ '
+}
+
+# Distinct values seen for a key, across the whole tree. This is the vocabulary
+# check: if "fredonia" and "Fredonia" both show up here, fix them now.
+fm_values() {
+ local key="$1"
+ # shellcheck disable=SC2046
+ find "$notes" $(find_prune_expr) -prune -o -type f -name '*.md' -print0 2>/dev/null \
+ | xargs -0 -r awk -v K="$key" '
+ FNR == 1 { infm = 0; started = 0; key = "" }
+ /^---[[:space:]]*$/ {
+ if (!started) { started = 1; infm = 1; next }
+ if (infm) { infm = 0; next }
+ }
+ infm {
+ if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*:/) {
+ key = $0; sub(/:.*/, "", key)
+ rest = $0; sub(/^[^:]*:[[:space:]]*/, "", rest)
+ } else {
+ rest = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", rest)
+ }
+ if (key != K) next
+ gsub(/^[\[ ]+|[\] ]+$/, "", rest)
+ n = split(rest, parts, /[[:space:]]*,[[:space:]]*/)
+ for (i = 1; i <= n; i++)
+ if (parts[i] != "") print parts[i]
+ }
+ ' | sort -u
+}
+
+fm_keys() {
+ # shellcheck disable=SC2046
+ find "$notes" $(find_prune_expr) -prune -o -type f -name '*.md' -print0 2>/dev/null \
+ | xargs -0 -r awk '
+ FNR == 1 { infm = 0; started = 0 }
+ /^---[[:space:]]*$/ {
+ if (!started) { started = 1; infm = 1; next }
+ if (infm) { infm = 0; next }
+ }
+ infm && /^[A-Za-z_][A-Za-z0-9_]*:/ { k = $0; sub(/:.*/, "", k); print k }
+ ' | sort | uniq -c | sort -nr | sed 's/^ *[0-9]* //'
+}
+
+query() {
+ local key val file
+ key="$(fm_keys | menu 'Key: ')" || return 0
+ [ -z "$key" ] && return 0
+ val="$(fm_values "$key" | menu "$key = ")" || return 0
+ [ -z "$val" ] && return 0
+ file="$(fm_files "$key" "$val" | sed "s|^$notes/||" | sort | menu 'Match: ')" || return 0
+ [ -n "$file" ] && open_file "$notes/$file"
+}
+
+# --------------------------------------------------------------- dispatch ---
+
+main() {
+ [ -d "$notes" ] || die "notes directory not found: $notes"
+ command -v dmenu >/dev/null || die 'dmenu not found'
+
+ local choice
+ choice="$( { printf '%s\n' \
+ '+ new note' \
+ '+ find by front matter' \
+ '+ todo.txt' \
+ '+ check-out.md' \
+ '+ calendar.rem' \
+ '+ lint notes'
+ list_notes
+ } | menu 'Note: ' )" || exit 0
+
+ case "$choice" in
+ '') exit 0 ;;
+ '+ new note') newnote ;;
+ '+ find by front matter') query ;;
+ '+ todo.txt') open_file "$notes/todo.txt" ;;
+ '+ check-out.md') open_file "$notes/check-out.md" ;;
+ '+ calendar.rem') open_file "$calendar/calendar.rem" ;;
+ '+ lint notes') setsid -f "$termemu" -e sh -c \
+ "'$lint' --root '$notes'; \
+ printf '\n[enter to close] '; read _" \
+ >/dev/null 2>&1 ;;
+ *.md|*.rem|*.txt) open_file "$notes/$choice" ;;
+ *) exit 0 ;;
+ esac
+}
-# This script is to add out-of the box features to my dmenu build. If you want
-# to make changes, make them in the source directory and reinstall.
-
-folder="${NOTES:-$HOME/Notes}"
-
-newnote () { \
- fType="$(echo -e "markdown\ngroff_ms\nspreadsheet\ncalendar" | dmenu -i -p 'File type: ')" || exit 0
- dir="$(command ls -d "$folder" "$folder"*/ | dmenu -i -p 'Choose directory: ')" || exit 0
- : "${dir:=$folder}"
- name="$(echo "" | dmenu -p "Enter a name: " <&-)" || exit 0
- : "${name:=$(date +%F_%H-%M-%S)}"
- case $fType in
- markdown) setsid -f "$TERMINAL" -e $EDITOR $dir$name".md" >/dev/null 2>&1 ;;
- groff_ms) setsid -f "$TERMINAL" -e $EDITOR $dir$name".ms" >/dev/null 2>&1 ;;
- spreadsheet) setsid -f "$TERMINAL" -e sc-im $dir$name".sc" >/dev/null 2>&1 ;;
- calendar) setsid -f "$TERMINAL" -e $EDITOR $dir$name".rem" >/dev/null 2>&1 ;;
- *) exit ;;
- esac
-}
-
-selected () { \
- choice=$(
- echo -e "New\n$(find $folder -type f -printf '%T@ %P\n' | sort -nr | cut -d' ' -f2-)" | dmenu -l 20 -i -p "Choose note or create new: "
- )
- case $choice in
- New) newnote ;;
- *.md) setsid -f "$TERMINAL" -e $EDITOR "$folder/$choice" >/dev/null 2>&1 ;;
- *.sc) setsid -f "$TERMINAL" -e sc-im "$folder/$choice" >/dev/null 2>&1 ;;
- *.ms) setsid -f "$TERMINAL" -e $EDITOR "$folder/$choice" >/dev/null 2>&1 ;;
- *.rem) setsid -f "$TERMINAL" -e $EDITOR "$folder/$choice" >/dev/null 2>&1 ;;
- *) exit ;;
- esac
-}
-
-selected
+main "$@"