#!/usr/bin/env bash
###############################################################################
# dnotes -- browse and create md-roam notes in ~/lit/slipbox with dmenu
###############################################################################
set -euo pipefail
NOTES_DIR="${NOTES_DIR:-$HOME/lit/slipbox}"
DMENU="${DMENU:-dmenu -i}"
NOTE_ICON=""
CREATE_ICON="+"
NOTE_TYPES=("evergreen" "paper" "person" "question" "letter" "lecture" "company")
NOTE_STATES=("active" "hidden")
NOTE_STATUSES=("rough" "growing" "evergreen" "abandoned")
NOTE_CERTAINTIES=("certain" "highly likely" "likely" "possible" "unlikely" "highly unlikely" "remote" "impossible")
trim() {
sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
notify() {
if command -v notify-send >/dev/null 2>&1; then
notify-send "dnotes" "$1"
fi
}
slugify() {
printf '%s' "$1" |
tr '[:upper:]' '[:lower:]' |
sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//'
}
yaml_quote() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '"%s"' "$value"
}
inline_list() {
local input="$1"
input=$(printf '%s' "$input" | trim)
if [ -z "$input" ]; then
printf '[]'
else
printf '[%s]' "$input"
fi
}
prompt_text() {
local prompt="$1"
local default="${2:-}"
local value
value=$(printf '%s' "$default" | $DMENU -p "$prompt") || exit 0
printf '%s' "$value" | trim
}
choose_one() {
local prompt="$1"
local default="$2"
shift 2
local value
value=$(printf '%s\n' "$@" | $DMENU -l "$#" -p "$prompt [$default]") || exit 0
if [ -z "$value" ]; then
printf '%s' "$default"
else
printf '%s' "$value"
fi
}
prompt_int_range() {
local prompt="$1"
local min="$2"
local max="$3"
local default="$4"
local value
while true; do
value=$(prompt_text "$prompt [$default]" "$default")
[ -z "$value" ] && value="$default"
if [[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge "$min" ] && [ "$value" -le "$max" ]; then
printf '%s' "$value"
return
fi
notify "$prompt must be between $min and $max"
done
}
prompt_timestamp() {
local prefix="$1"
local year month day hour minute second
year=$(prompt_int_range "$prefix year" 1 9999 "$(date -u +%Y)")
month=$(prompt_int_range "$prefix month" 1 12 "$(date -u +%m)")
day=$(prompt_int_range "$prefix day" 1 31 "$(date -u +%d)")
hour=$(prompt_int_range "$prefix hour UTC" 0 23 "$(date -u +%H)")
minute=$(prompt_int_range "$prefix minute UTC" 0 59 "$(date -u +%M)")
second=$(prompt_int_range "$prefix second UTC" 0 59 "$(date -u +%S)")
if ! date -u -d "$year-$month-$day $hour:$minute:$second" >/dev/null 2>&1; then
notify "Invalid date"
prompt_timestamp "$prefix"
return
fi
printf '%04d-%02d-%02dT%02d:%02d:%02dZ' "$year" "$month" "$day" "$hour" "$minute" "$second"
}
open_in_emacs() {
local file="$1"
local escaped="${file//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
if emacsclient -e "(window-system)" 2>/dev/null | grep -q "x"; then
emacsclient -e "(progn
(split-window-right 80)
(other-window 1)
(find-file \"$escaped\")
(when (fboundp 'kris/prose-wrap-mode) (kris/prose-wrap-mode))
(raise-frame)
(select-frame-set-input-focus (selected-frame))
(when (fboundp 'evil-normal-state) (evil-normal-state)))" >/dev/null 2>&1 || emacsclient -n "$file"
else
emacs "$file" &
fi
}
sync_note_in_emacs() {
local file="$1"
local escaped="${file//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
emacsclient -e "(when (fboundp 'org-roam-db-update-file)
(org-roam-db-update-file \"$escaped\"))" >/dev/null 2>&1 || true
}
extract_title() {
local file="$1"
awk '
NR == 1 && $0 == "---" { fm = 1; next }
fm && $0 == "---" { exit }
fm && $0 ~ /^title:[[:space:]]*/ {
sub(/^title:[[:space:]]*/, "")
gsub(/^["'\'']|["'\'']$/, "")
print
exit
}
' "$file"
}
create_note() {
mkdir -p "$NOTES_DIR"
local title slug default_slug type stime etime state status certainty importance preview linked_notes tags file
title=$(prompt_text "Title")
[ -z "$title" ] && exit 0
default_slug=$(slugify "$title")
slug=$(prompt_text "Slug" "$default_slug")
[ -z "$slug" ] && slug="$default_slug"
type=$(choose_one "Type" "evergreen" "${NOTE_TYPES[@]}")
stime=$(prompt_timestamp "Created")
if [ "$(choose_one "Set completion time?" "no" "no" "yes")" = "yes" ]; then
etime=$(prompt_timestamp "Completed")
else
etime=""
fi
state=$(choose_one "State" "active" "${NOTE_STATES[@]}")
status=$(choose_one "Status" "rough" "${NOTE_STATUSES[@]}")
certainty=$(choose_one "Certainty" "possible" "${NOTE_CERTAINTIES[@]}")
importance=$(prompt_int_range "Importance" 0 10 5)
preview=$(prompt_text "Preview")
linked_notes=$(prompt_text "Linked note slugs, comma-separated")
tags=$(prompt_text "Tags, comma-separated")
file="$NOTES_DIR/$slug.md"
if [ -e "$file" ]; then
notify "Note already exists: $slug.md"
exit 1
fi
{
printf '%s\n' '---'
printf 'title: %s\n' "$(yaml_quote "$title")"
printf 'slug: %s\n' "$slug"
printf 'type: %s\n' "$type"
printf 'stime: %s\n' "$stime"
printf 'etime: %s\n' "$etime"
printf 'state: %s\n' "$state"
printf 'status: %s\n' "$status"
printf 'certainty: %s\n' "$certainty"
printf 'importance: %s\n' "$importance"
printf 'preview: %s\n' "$(yaml_quote "$preview")"
printf 'backlinks: 0\n'
printf 'linkedNotes: %s\n' "$(inline_list "$linked_notes")"
printf 'tags: %s\n' "$(inline_list "$tags")"
printf '%s\n\n' '---'
} > "$file"
sync_note_in_emacs "$file"
open_in_emacs "$file"
notify "Created: $slug.md"
}
open_note() {
local files menu choice slug file title
files=$(find "$NOTES_DIR" -maxdepth 1 -type f -name '*.md' | sort)
if [ -z "$files" ]; then
notify "No notes in $NOTES_DIR"
exit 1
fi
menu=""
while IFS= read -r file; do
slug=$(basename "$file" .md)
title=$(extract_title "$file")
[ -z "$title" ] && title="$slug"
menu+="$title [$slug]"$'\n'
done <<< "$files"
choice=$(printf '%s' "$menu" | $DMENU -l 20 -p "$NOTE_ICON Open note") || exit 0
[ -z "$choice" ] && exit 0
slug=$(printf '%s' "$choice" | sed 's/.*\[//; s/\]$//')
file="$NOTES_DIR/$slug.md"
if [ -f "$file" ]; then
open_in_emacs "$file"
else
notify "File not found: $slug.md"
exit 1
fi
}
main() {
local choice
choice=$(printf 'open\ncreate\n' | $DMENU -l 2 -p "$NOTE_ICON Notes") || exit 0
case "$choice" in
open) open_note ;;
create) create_note ;;
*) exit 0 ;;
esac
}
main "$@"