Files
agenda/agenda

407 lines
8.4 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# The editor used to edit agenda note files.
AGENDA_EDITOR=${AGENDA_EDITOR:-${VISUAL:-${EDITOR:-vi}}}
# The home of all files read and written by agenda.
#
# ```
# $AGENDA_DIR
# ├── {agenda-name}
# │ ├── 0000-{backlog-name}.{agenda-ext}
# │ └── {agenda-date}.{agenda-ext}
# └── {agenda-name}
# └── {agenda-date}.{agenda-ext}
# ```
#
AGENDA_DIR=${AGENDA_DIR:-}
# The default `-e` option.
AGENDA_EXTENSION=${AGENDA_EXTENSION:-"md"}
# The default name if the `-t` option is omitted.
AGENDA_DEFAULT=${AGENDA_DEFAULT:-"agenda"}
# Command that provides the initial contents of new agenda files.
AGENDA_TEMPLATE=${AGENDA_TEMPLATE:-"__agenda_default_template"}
__usage() {
fold -s <<-USAGE
Usage: ${0##*/} [-bcE] [-e <extension>] [-t <name>] [<date>]
${0##*/} <subcommand>
Manage daily tasks and notes in any plain text format.
-b Edit a backlog file (use the date argument as backlog name)
-c Create the file if it does not exist
-E Instead of editing the file, print its path to STDOUT
-e <extension> Specify the file extension
-t <name> Specify which agenda to edit
Subcommands:
edit Edit a single agenda note file. This is the default and can be
omitted.
list List a range of agenda note files.
USAGE
}
__version() {
echo "${0##*/} version 0.0.2"
}
__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 <extension>] [-t <name>] [<date>]
Edit a single agenda note file.
-b Edit a backlog file (use the date argument as backlog name)
-c Create the file if it does not exist
-E Instead of editing the file, print its path to STDOUT
-e <extension> Specify the file extension
-t <name> 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_EXTENSION"
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
# These values are (potentially) used by the AGENDA_EDITOR and AGENDA_TEMPLATE
# processes.
AGENDA_FILE=$file
AGENDA_DATE=$base
AGENDA_NAME=$opt_t
AGENDA_SLUG=$slug
AGENDA_FILE=$file
AGENDA_LAST=$(__agenda_last "$file" "$opt_t")
# Make shellcheck happy...
export AGENDA_FILE AGENDA_DATE AGENDA_NAME AGENDA_SLUG AGENDA_FILE AGENDA_LAST
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
exec $AGENDA_EDITOR "$file"
else
echo "$file"
fi
}
__list_usage() {
fold -s <<-USAGE
Usage: ${0##*/} list [-ac] [-u <date>] [-t <name>] [<date>]
List a range of agenda note files.
-a Print the files' absolute path
-c Print the files' content
-u <date> Stop listing at <date>
-t <name> 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_cmp until_cmp
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
trap 'rm -f "${tmp:-}" "${tmp_err:-}"; trap - RETURN' RETURN
local tmp tmp_err
tmp=$(mktemp)
tmp_err=$(mktemp)
if [ "$backlog" = "no" ]; then
# Capture exit code of the template command.
local code
set +e
"$AGENDA_TEMPLATE" > "$tmp" 2> "$tmp_err"
code=$?
set -e
if [ $code -eq 127 ]; then
echo "${0##*/}: cannot create '$file': template '$AGENDA_TEMPLATE' does not exist" >&2
return 127
fi
if [ -s "$tmp_err" ]; then
while read -r REPLY; do
printf "agenda: $AGENDA_TEMPLATE: %s\n" "${REPLY#"${0}: line"*": "}" >&2
done < "$tmp_err"
fi
if [ $code -gt 0 ]; then
echo "${0##*/}: cannot create '$file': template error" >&2
return $code
fi
fi
mkdir -p "${file%/*}"
mv "$tmp" "$file"
}
__agenda_default_template() {
case $AGENDA_EXTENSION in
"md")
printf "# %s %s\n\n- [ ] " "$AGENDA_NAME" "$AGENDA_DATE"
;;
"txt"|"xit"|"")
printf "%s %s\n[ ] " "$AGENDA_NAME" "$AGENDA_DATE"
;;
*)
;;
esac
}
__agenda_last() {
local file=$1 name=$2
[ -d "$AGENDA_DIR/$name" ] || return 0
local last
while read -r REPLY; do
if [ "$REPLY" = "$file" ]; then
break
else
last=$REPLY
fi
done < <(
{
echo "$file"
find "$AGENDA_DIR/$name" -type f
} | sort
)
echo "${last:-}"
}
# 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