]> git.newer.systems Git - dmenu.git/commitdiff
note-struct main
authorTucker Johnson <tucker@newer.systems>
Sun, 23 Aug 2026 16:55:50 +0000 (12:55 -0400)
committerTucker Johnson <tucker@newer.systems>
Sun, 23 Aug 2026 16:55:50 +0000 (12:55 -0400)
extras/dmenu-scripts/dm-notepick

index daa42a57333e64bd3e5270b85706dfd6d58816b5..852e5f1cb92a4c0a147c5d975dc0c0e853c6f16b 100755 (executable)
 #!/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 "$@"