diff --git a/agenda b/agenda new file mode 100644 index 0000000..e9ea816 --- /dev/null +++ b/agenda @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The home of all files read and written by agenda. +# +# If this is left empty, this will default to the xdg user DOCUMENTS directory (in a +# child agenda directory), if `xdg-user-dir` is in PATH. Otherwise it will default +# to `~/.agenda`. +# +# ``` +# $AGENDA_DIR +# ├── {agenda-name} +# │ ├── 0000-{backlog-name}.md +# │ ├── 1970-01-01.md +# │ ├── 1970-01-02.md +# │ └── 1970-01-03.md +# └── {agenda-name} +# ├── 1970-01-01.md +# ├── 1970-01-02.md +# └── 1970-01-03.md +# ``` +# +AGENDA_DIR=${AGENDA_DIR:-} + +# The default `-e` option. +AGENDA_EXT=${AGENDA_EXT:-"md"} + +# The default name if the `-t` option is omitted. +AGENDA_DEFAULT=${AGENDA_DEFAULT:-"agenda"} + +# A template that is automatically written to any created agenda file (excluding +# backlogs). A few magic strings are replaced: +# +# - `{date}` The agenda date (`%Y-%m-%d`) +# - `{name}` The agenda name +# - `{slug}` The agenda name (slugged) +AGENDA_TMPL="${AGENDA_TMPL:-"# {name} {date}\\n\\n- [ ] "}" + +__usage() { + fold -s <<-USAGE + Usage: ${0##*/} [-bcE] [-e ] [-t ] [] + ${0##*/} + + Organize and edit your agenda in any plain text format. + + -b Backlog (use the date argument as backlog name) + -c Create the file if it does not exist + -E Instead of editing the file, print the path to STDOUT + -e Specify the file extension + -t Specify which agenda to edit + + Subcommands: + edit The default, if subcommand is omitted. + list List a range of agenda files. + USAGE +} + +__version() { + echo "${0##*/} version 0.0.1" +} + +__main() { + case $* in + "--help") __usage && return ;; + "--version") __version && return ;; + esac + + if [ -z "$AGENDA_DIR" ]; then + if type xdg-user-dir >/dev/null 2>&1; then + AGENDA_DIR="$(xdg-user-dir DOCUMENTS)/agenda" + else + AGENDA_DIR="$HOME/.agenda" + fi + fi + + # Subcommand + case ${1:-} in + "list"|"ls") __list "${@:2}" ;; + "edit") __edit "${@:2}" ;; + *) __edit "$@" ;; + esac +} + +__edit_usage() { + fold -s <<-USAGE + Usage: ${0##*/} edit [-bcE] [-e ] [-t ] [] + + Edit an agenda file. + + -b Backlog (use the date argument as backlog name) + -c Create the file if it does not exist + -E Instead of editing the file, print the path to STDOUT + -e Specify the file extension + -t Specify which agenda to edit + USAGE +} + +__edit() { + case $* in + "--help") __edit_usage && return ;; + esac + + local opt_b opt_c opt_E opt_e opt_t + opt_b="no" + opt_c="no" + opt_E="no" + opt_e="$AGENDA_EXT" + opt_t="$AGENDA_DEFAULT" + while getopts 'bcEe:t:' opt; do + case $opt in + b) + opt_b="yes" + ;; + c) + opt_c="yes" + ;; + E) + opt_E="yes" + ;; + e) + opt_e=$OPTARG + ;; + t) + opt_t=$OPTARG + ;; + *) + __edit_usage && return 1 + ;; + esac + done + shift $(( OPTIND - 1 )) + + if [ $# -eq 0 ]; then + if [ "$opt_b" = "yes" ]; then + set -- "backlog" + else + set -- "today" + fi + fi + + local base ext slug file + if [ "$opt_b" = "yes" ]; then + base="0000-$(___slugify "$*")" + else + base=$(date -d "$*" +%F) + fi + ext=$opt_e + slug=$(___slugify "$opt_t") + file="$AGENDA_DIR/$slug/$base.$ext" + + # Existing files may overwrite the `-e` option. + local exists + for exists in "${file%."$ext"}".*; do + if [ -e "$exists" ] && [ "$file" != "$exists" ]; then + echo "${0##*/}: ignoring '$ext' extension: '$exists' already exists" >&2 + ext="${exists##*.}" + file="$exists" + fi + done + + if ! [ -f "$file" ]; then + if [ "$opt_c" = "yes" ]; then + __agenda_create "$file" "$opt_b" "$opt_t" "$slug" "$base" + else + echo "${0##*/}: cannot access '$file': No such file" >&2 + return 2 + fi + fi + + if [ "$opt_E" = "no" ]; then + export AGENDA_FILE="$file" + exec ${VISUAL:-${EDITOR:-vi}} "$file" + else + echo "$file" + fi +} + +__list_usage() { + fold -s <<-USAGE + Usage: ${0##*/} list [-ac] [-u ] [-t ] [] + + List a range of agenda files. + + -a Print the files' absolute path + -c Print the files' content + -u Stop listing at + -t Specify which agenda to list + USAGE +} + +__list() { + case $* in + "--help") __list_usage && return ;; + esac + + local opt opt_a opt_c opt_u opt_t + opt_a="no" + opt_c="no" + opt_u="next week" # FIXME: Not portable. + opt_t="" + while getopts 'abcu:t:' opt; do + case $opt in + a) + opt_a="yes" + ;; + c) + opt_c="yes" + ;; + u) + opt_u="$OPTARG" + ;; + t) + opt_t="$OPTARG" + ;; + *) + list_usage + return 1 + ;; + esac + done + shift $(( OPTIND - 1 )) + + # TODO: Check if stdout is a file that is a terminal or a pipe that is + # connected to a terminal. + local padlength + padlength=80 + + if [ $# -eq 0 ]; then + # FIXME: Not portable. + set -- "last week" + fi + + local date until dir + date=$(date -d "$*" +%F) + until=$(date -d "$opt_u" +%F) + if [ -n "$opt_t" ]; then + slug=$(slugify "$opt_t") + dir="$AGENDA_DIR/$slug" + else + dir="$AGENDA_DIR" + fi + + if ! [ -d "$dir" ]; then + echo "${0##*/}: cannot access '$dir': Directory does not exist" >&2 + return 1 + fi + + local date_cmd until_cmd + date_cmp="${date%%-*}${date#*-}" + date_cmp="${date_cmp%%-*}${date_cmp#*-}" + until_cmp="${until%%-*}${until#*-}" + until_cmp="${until_cmp%%-*}${until_cmp#*-}" + + while read -r REPLY; do + if ! [ -f "$REPLY" ]; then + echo "${0##*/}: cannot access '$REPLY': File does not exist" >&2 + fi + + local file_date + file_date="${REPLY##*/}" + file_date="${file_date%.*}" + + local file_date_cmp + file_date_cmp="${file_date%%-*}${file_date#*-}" + file_date_cmp="${file_date_cmp%%-*}${file_date_cmp#*-}" + + # Agenda may allow special files that are not named by date in the + # future. + # https://stackoverflow.com/a/18620446 + case $file_date_cmp in + *[!0-9]* | '') continue ;; + *) ;; + esac + + # This was originally written in POSIX shell. Let's keep it that way for + # now. + # https://unix.stackexchange.com/a/84472 + if [ "$file_date_cmp" -ge "$date_cmp" ] && [ "$file_date_cmp" -le "$until_cmp" ]; then + local file_name + file_name="$REPLY" + if [ "$opt_a" = "no" ]; then + file_name="${file_name#"$AGENDA_DIR/"}" + file_name="${file_name%.*}" + fi + + if [ "$opt_c" = "yes" ]; then + printf "%*s\n" "$padlength" "[$file_name]" | tr " " "-" + cat "$REPLY" + echo + else + echo "$file_name" + fi + fi + done < <( + # Sort the paths by basename. + find "$dir" -type f \ + | __agenda_sort_prefix \ + | sort \ + | __agenda_strip_sort_prefix + ) +} + +__agenda_create() { + local file=$1 backlog=$2 name=$3 slug=$4 date=$5 + + if [ -e "$file" ]; then + echo "${0##*/}: cannot create '$file': File already exists" >&2 + return 1 + fi + + local content + if [ "$backlog" = "no" ]; then + content=$(echo "$AGENDA_TMPL" \ + | sed "s/{date}/$date/g" \ + | sed "s/{name}/$name/g" \ + | sed "s/{slug}/$slug/g") + fi + + mkdir -p "${file%/*}" + + # shellcheck disable=SC2059 + printf "${content:-}" > "$file" +} + +# https://stackoverflow.com/a/49035906 +___slugify() { + echo "$1" \ + | iconv -c -t ascii//TRANSLIT \ + | sed -E "s/[~^]+//g" \ + | sed -E "s/[^a-zA-Z0-9]+/-/g" \ + | sed -E "s/^-+|-+$//g" \ + | tr "[:upper:]" "[:lower:]" +} + +__agenda_sort_prefix() { + while read -r REPLY; do + echo "${REPLY##*/}---$REPLY" + done +} + +__agenda_strip_sort_prefix() { + while read -r REPLY; do + echo "${REPLY#*---}" + done +} + +__main "$@" + +# vi: noet sts=4 sw=4 tw=84