#!/bin/sh set -eu MSGS='' add_msg() { MSGS="$MSGS $1" } MSG_USAGE="$(cat <<-'EOF' Usage: @NAME@ [-c] [-m MESSAGE] [-t TYPE] [-s STATE] @NAME@ [-HlL] @NAME@ [-hV] EOF )" add_msg USAGE MSG_HELP1="$(cat <<-'EOF' Options: -c commit directly only with the title, without adding a description. Assumes -m -m MESSAGE_TITLE the title message of the entry -t TYPE the type of entry to be added (default: |TYPE|) -s STATE the state of entry to be added (default: |STATE|) -l list the supported values for $TD_USE_BUILTIN_HOOKS -L lint the issues file -H pre-process issues file before generating HTML -h, --help show this help message -V, --version print the version number Examples: Create a new entry with the default type with a custom title: $ td -cm 'An entry title' Create a new entry with a custom type and state: $ td -cm 'Another title' -t bug -s DONE States of an entry: EOF )" add_msg HELP1 MSG_HELP2="$(cat <<-'EOF' Types of entry: EOF )" add_msg HELP2 MSG_HELP3="$(cat <<-'EOF' See "man @NAME@.tutorial" for getting started. EOF )" add_msg HELP3 MSG_UNEDITED_ENTRY="$(cat <<-'EOF' Abandoning unedited entry. EOF )" add_msg UNEDITED_ENTRY MSG_UNREGISTERED_TYPE="$(cat <<-'EOF' Unregistered type: %s EOF )" add_msg UNREGISTERED_TYPE MSG_UNREGISTERED_STATE="$(cat <<-'EOF' Unregistered state: %s EOF )" add_msg UNREGISTERED_TYPE MSG_ADDED_LOG="$(cat <<-'EOF' %s added to %s. EOF )" add_msg ADDED_LOG MSG_COMMIT_MSG="$(cat <<-'EOF' $TD_FILE: Add $TD_IDREF EOF )" add_msg COMMIT_MSG MSG_TYPE_DOESNT_EXIST_CREATING="$(cat <<-'EOF' Type "%s" doesn't exist yet, creating new section with it. EOF )" add_msg TYPE_DOESNT_EXIST_CREATING MSG_FILE_DOESNT_EXIST_CREATING="$(cat <<-'EOF' File "%s" doesn't exist yet, creating a brand new one. EOF )" add_msg FILE_DOESNT_EXIST_CREATING # # End translatable strings # dump_translatable_strings() { for msg in $MSGS; do eval "echo \"\$MSG_$msg\"" > src/locale/"$msg".en.txt done cat <<-'EOF' #!/bin/sh # shellcheck disable=2034 set -eu EOF for msg in $MSGS; do printf 'MSG_%s="$(cat @LOCALEDIR@/@LANG@/LC_MESSAGES/@NAME@/%s.@LANG@.txt)"\n' \ "$msg" "$msg" done } if [ -n "${TD_DUMP_TRANSLATABLE_STRINGS:-}" ]; then dump_translatable_strings exit fi if [ -r '@LIBEXECDIR@/@NAME@/load-messages.sh' ]; then . '@LIBEXECDIR@/@NAME@/load-messages.sh' fi # # Documentation functions # usage() { printf '%s\n' "$MSG_USAGE" } help() { printf '%s\n' "$MSG_HELP1" | sed -e "s/|TYPE|/$DEFAULT_TYPE/" \ -e "s/|STATE|/$DEFAULT_STATE/" echo "$TD_STATES" | sed -e 's/^/ /' printf '\n%s\n' "$MSG_HELP2" echo "$TD_TYPES" | awk -F: '{ printf " %s\n", $1 }' printf '\n%s\n' "$MSG_HELP3" } version() { printf '@NAME@-@VERSION@ @DATE@\n' } # # Utilities # uuid() { # Taken from: # https://serverfault.com/a/799198 od -xN20 /dev/urandom | head -n 1 | awk '{OFS="-"; print $2$3,$4,$5,$6,$7$8$9}' } mkstemp() { F="${TMPDIR:-/tmp}/td-${1:-$(uuid)}" touch "$F" echo "$F" } mkdtemp() { F="${TMPDIR:-/tmp}/td-${1:-$(uuid)}" mkdir -p "$F" echo "$F" } # # Core functions # optimal_line_for_type() { GOAL_TYPE="$1" GOAL_INDEX="$(echo "$TD_TYPES" | awk "/^$GOAL_TYPE:/ { printf NR }")" TYPE_HEADINGS="$(mkstemp)" echo "$TD_TYPES" | cut -d: -f2- | sed 's/^/# /' > "$TYPE_HEADINGS" IN_TD_SECTION=false IN_TD_ENTRY=false LINE_NUMBER=0 LAST_MATCH=0 IN_TD_REGION=false while read -r line; do LINE_NUMBER=$((LINE_NUMBER + 1)) if ! echo "$line" | grep -Eq '^# .+$'; then continue fi MATCH="$(grep -nF "$line" "$TYPE_HEADINGS" ||:)" if [ -n "$MATCH" ]; then MATCH_N="$(echo "$MATCH" | cut -d: -f1)" MATCH_LINE="$(echo "$MATCH" | cut -d: -f2-)" if [ "$line" = "$MATCH_LINE" ]; then IN_TD_REGION=true if [ "$MATCH_N" -lt "$GOAL_INDEX" ]; then LAST_MATCH="$MATCH_N" else echo $((LINE_NUMBER - 1)) return fi fi elif [ "$IN_TD_REGION" = true ]; then echo $((LINE_NUMBER - 1)) return fi done < "$TD_FILE" echo $((LINE_NUMBER)) } insert_at_line() { N="$1" F="$2" TYPE="$3" TMPF="$(mkstemp)" if [ ! -e "$F" ]; then printf "$MSG_FILE_DOESNT_EXIST_CREATING\n" "$TD_FILE" >&2 cat - > "$TMPF" elif [ "$N" = 0 ]; then DESIRED_N="$(optimal_line_for_type "$TYPE")" printf "$MSG_TYPE_DOESNT_EXIST_CREATING\n" "$TYPE" >&2 printf '%s\n\n\n%s\n\n\n%s\n' \ "$(head -n "$DESIRED_N" "$F")" \ "$(cat -)" \ "$(tail -n +$((DESIRED_N+1)) "$F")" \ > "$TMPF" else printf '%s\n\n%s\n\n%s\n' \ "$(head -n "$N" "$F")" \ "$(cat -)" \ "$(tail -n +$((N+1)) "$F")" \ > "$TMPF" fi cp "$TMPF" "$F" } name_for_type() { TYPE="$1" echo "$TD_TYPES" | awk -F: "/^$TYPE:/ { print \$2; exit }" } linenumber_for_type() { TYPE="$1" if [ ! -e "$TD_FILE" ]; then echo '' else NAME="$(name_for_type "$TYPE")" awk -F: "/^# $NAME\$/ {print NR; exit}" "$TD_FILE" fi } GIT_BUILTIN_PRE_ADD_HOOK="$(cat <<-EOF if ! git diff --exit-code --quiet -- "\$TD_FILE" || ! git diff --exit-code --quiet --staged -- "\$TD_FILE" then git stash push --quiet -- "\$TD_FILE" touch "\$TD_HOOKS_DIR/has-diff" fi EOF )" GIT_BUILTIN_POST_ADD_HOOK="$(cat <<-EOF git add "\$TD_FILE" git commit -m "$MSG_COMMIT_MSG" if [ -e "\$TD_HOOKS_DIR/has-diff" ]; then git stash pop --quiet fi EOF )" load_hooks() { case "${TD_USE_BUILTIN_HOOKS:-}" in git) export TD_PRE_ADD_HOOK="${TD_PRE_ADD_HOOK:-$GIT_BUILTIN_PRE_ADD_HOOK}" export TD_POST_ADD_HOOK="${TD_POST_ADD_HOOK:-$GIT_BUILTIN_POST_ADD_HOOK}" ;; *) ;; esac } list_hooks() { for t in git; do TD_USE_BUILTIN_HOOKS="$t" load_hooks printf '%s:\n' "$t" printf ' TD_PRE_ADD_HOOK:\n' printf '%s\n' "$TD_PRE_ADD_HOOK" | sed 's/^/ /' printf ' TD_POST_ADD_HOOK:\n' printf '%s\n' "$TD_POST_ADD_HOOK" | sed 's/^/ /' done } pre_add_hook() { sh -c "${TD_PRE_ADD_HOOK:-}" } post_add_hook() { sh -c "${TD_POST_ADD_HOOK:-}" } # # HTML pre-processing # html_inject_checkboxes() { awk '{ if (match($0, /^(-|[0-9]*.) \[( |x)\] /)) { printf \ "%s %s\n", $1, substr($2, 2, 1) == "x" ? "checked " : "", substr($0, 1 + length($1) + length(" [ ] ")) } else { print } }' } html_process_tags() { sed 's|tag:\([^ ]*\)|\1|g' } html_process_ids() { STATES_REGEX="$( echo "$TD_STATES" | grep . | paste -sd '|' | sed 's/|/\\|/g' | printf '\(%s\)' "$(cat -)" )" sed 's:^## '"$STATES_REGEX"' \(.*\) {#\(.*\)}\(.*\)$:## \1 \2\4\
#\3\ :g' } html_pre_process() { html_inject_checkboxes | html_process_tags | html_process_ids } # # Linting # lint_assert_order() { GREP_FLAGS="$(echo "$TD_TYPES" | cut -d: -f2- | sed 's/\(.*\)/-e "\1"/' | tr '\n' ' ')" echo eval "grep -F $GREP_FLAGS $TD_FILE" } known_heading() { line="$1" TYPE_HEADINGS="$2" while read -r heading; do if [ "$heading" = "$line" ]; then return 0 fi done < "$TYPE_HEADINGS" return 1 } warning() { echo "$1" "$2" "$3" } lint() { return 0 TYPE_HEADINGS="$(mkstemp)" echo "$TD_TYPES" | cut -d: -f2- | sed 's/^/# /' > "$TYPE_HEADINGS" IN_TD_SECTION=false IN_TD_ENTRY=false LINE_NUMBER=0 while read -r line; do LINE_NUMBER=$((LINE_NUMBER + 1)) if [ "$IN_TD_SECTION" = false ]; then if known_heading "$line" "$TYPE_HEADINGS"; then IN_TD_SECTION=true fi fi if [ "$IN_TD_SECTION" = false ]; then continue fi if echo "$line" | grep -q '^# .*' && ! known_heading "$line" "$TYPE_HEADINGS"; then break else IN_TD_ENTRY=false echo "$line" | grep -q '^#.*' # continue fi if echo "$line" | grep -q '^## .*'; then IN_TD_ENTRY=true fi if [ "$IN_TD_ENTRY" = false ] && [ "$line" != '' ]; then warning "$TD_FILE" "$LINE_NUMBER" \ 'Content outside of td entry' fi done < "$TD_FILE" } # # Default values, config "run commands" file and derived values # ID="$(uuid)" export TD_IDREF="#td-$ID" export TD_FILE='TODOs.md' export TD_TYPES="$(cat <<-'EOF' task:Tasks bug:Bugs improvement:Improvements question:Questions decision:Decisions idea:Ideas proposal:Proposals EOF )" export TD_STATES="$(cat <<-'EOF' TODO DOING WAITING INACTIVE NEXT CANCELLED DONE WONTFIX EOF )" export TD_HOOKS_DIR="$(mkdtemp)" if [ -r "$PWD/.tdrc" ]; then . "$PWD/.tdrc" fi DEFAULT_TYPE="$(echo "$TD_TYPES" | awk -F: '{ print $1; exit }')" DEFAULT_STATE="$(echo "$TD_STATES" | awk -F: '{ print $1; exit }')" TYPE="$DEFAULT_TYPE" STATE="$DEFAULT_STATE" NAME="$(name_for_type "$TYPE")" load_hooks # # Main # for flag in "$@"; do case "$flag" in --) break ;; --help) usage help exit ;; --version) version exit ;; *) ;; esac done SHORT=false MESSAGE=FIXME while getopts 'cm:t:s:lLHhV' flag; do case "$flag" in c) SHORT=true ;; m) MESSAGE="$OPTARG" ;; t) TYPE="$OPTARG" NAME="$(name_for_type "$TYPE")" if [ -z "$NAME" ]; then printf "$MSG_UNREGISTERED_TYPE\n" "$TYPE" >&2 exit 1 fi ;; s) STATE="$OPTARG" if ! echo "$TD_STATES" | grep -q "$STATE"; then printf "$MSG_UNREGISTERED_STATE\n" "$STATE" >&2 exit 1 fi ;; l) list_hooks exit ;; L) lint exit ;; H) html_pre_process < "$TD_FILE" exit ;; h) usage help exit ;; V) version exit ;; *) usage >&2 exit 2 ;; esac done shift $((OPTIND - 1)) TYPE_LINE="$(linenumber_for_type "$TYPE")" if [ -z "$TYPE_LINE" ]; then TYPE_LINE=-1 PREAMBLE="# $(name_for_type "$TYPE")"' ' fi INSERT_LINE=$((TYPE_LINE + 1)) TITLE_LINE="${PREAMBLE:-}$(printf '## %s %s {%s}\n- %s in %s\n' "$STATE" "$MESSAGE" "$TD_IDREF" "$STATE" "$(date -I)")" if [ "$SHORT" = 'true' ] && [ "$MESSAGE" != 'FIXME' ]; then pre_add_hook echo "$TITLE_LINE" | insert_at_line "$INSERT_LINE" "$TD_FILE" "$TYPE" post_add_hook else TMPFILE_ORIG="$(mkstemp)" TMPFILE_COPY="$(mkstemp)" printf '%s\n\n---\n\nFIXME\n' "$TITLE_LINE" > "$TMPFILE_ORIG" cp "$TMPFILE_ORIG" "$TMPFILE_COPY" CMD="${VISUAL:-${EDITOR:-vi}}" $CMD "$TMPFILE_ORIG" if diff "$TMPFILE_ORIG" "$TMPFILE_COPY" > /dev/null; then echo "$MSG_UNEDITED_ENTRY" >&2 exit 1 fi pre_add_hook insert_at_line "$INSERT_LINE" "$TD_FILE" "$TYPE" < "$TMPFILE_ORIG" post_add_hook fi printf "$MSG_ADDED_LOG\n" "$TD_IDREF" "$TD_FILE" >&2