From 5759cf29a6808e4f96c52fcdf89cddb69161e495 Mon Sep 17 00:00:00 2001 From: Tucker Johnson Date: Sun, 23 Aug 2026 12:55:50 -0400 Subject: [PATCH] note-struct --- extras/dmenu-scripts/dm-notepick | 546 +++++++++++++++++++++++++++++-- 1 file changed, 510 insertions(+), 36 deletions(-) diff --git a/extras/dmenu-scripts/dm-notepick b/extras/dmenu-scripts/dm-notepick index daa42a5..852e5f1 100755 --- a/extras/dmenu-scripts/dm-notepick +++ b/extras/dmenu-scripts/dm-notepick @@ -1,39 +1,513 @@ #!/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/// +# teaching/lessons// +# compositions// +# 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 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 [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///.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 matching . 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 "$@" -- 2.39.5