nag/nag

1156 lines
29 KiB
Bash
Executable file

#!/usr/bin/env bash
# nag is a bash script for setting one-off or repeating alarms.
# MIT License
#
# Copyright (c) 2026 Lewis Wynne
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Enforce Bash "strict mode".
set -o nounset
set -o errexit
set -o errtrace
set -o pipefail
trap 'echo "Aborting due to errexit on line $LINENO. Exit code: $?" >&2' ERR
IFS=$'\n\t'
_ME="$(basename "${0}")"
_VERSION="2026.14"
NAG_PATH="${NAG_PATH:-${HOME}/.local/share/nag}"
_LOCKFILE="${NAG_PATH}.lock"
# The command nag runs to execute its notifications.
NAG_CMD="${NAG_CMD:-notify-send}"
# The default subcommand if no args are passed.
NAG_DEFAULT="${NAG_DEFAULT:-list}"
_VALID_RULES=(
hour day weekday weekend
monday tuesday wednesday thursday friday saturday sunday
month year
)
# The fallback subcommand if an arg is passed that is not defined.
_FALLBACK_COMMAND_IF_NO_MATCH="at"
# Usage:
# _debug <command> <options>...
#
# Description:
# Execute a command and print to standard error. The command is expected to
# print a message and should typically be either `echo`, `printf`, or `cat`.
#
# Example:
# _debug printf "Debug info. Variable: %s\\n" "$0"
__DEBUG_COUNTER=0
_debug() {
if ((${_USE_DEBUG:-0}))
then
__DEBUG_COUNTER=$((__DEBUG_COUNTER+1))
{
# Prefix debug message with "bug (U+1F41B)"
printf "🐛 %s " "${__DEBUG_COUNTER}"
"${@}"
printf "―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――\\n"
} 1>&2
fi
}
# Usage:
# _exit_1 <command>
#
# Description:
# Exit with status 1 after executing the specified command with output
# redirected to standard error. The command is expected to print a message
# and should typically be either `echo`, `printf`, or `cat`.
_exit_1() {
{
printf "%s " "$(tput setaf 1)!$(tput sgr0)"
"${@}"
} 1>&2
exit 1
}
# Usage:
# _warn <command>
#
# Description:
# Print the specified command with output redirected to standard error.
# The command is expected to print a message and should typically be either
# `echo`, `printf`, or `cat`.
_warn() {
{
printf "%s " "$(tput setaf 1)!$(tput sgr0)"
"${@}"
} 1>&2
}
# Usage:
# _function_exists <name>
#
# Exit / Error Status:
# 0 (success, true) If function with <name> is defined in the current
# environment.
# 1 (error, false) If not.
_function_exists() {
[ "$(type -t "${1}")" == 'function' ]
}
# Usage:
# _command_exists <name>
#
# Exit / Error Status:
# 0 (success, true) If a command with <name> is defined in the current
# environment.
# 1 (error, false) If not.
_command_exists() {
hash "${1}" 2>/dev/null
}
# Usage:
# _contains <query> <list-item>...
#
# Exit / Error Status:
# 0 (success, true) If the item is included in the list.
# 1 (error, false) If not.
#
# Examples:
# _contains "${_query}" "${_list[@]}"
_contains() {
local _query="${1:-}"
shift
if [[ -z "${_query}" ]] ||
[[ -z "${*:-}" ]]
then
return 1
fi
for __element in "${@}"
do
[[ "${__element}" == "${_query}" ]] && return 0
done
return 1
}
# Usage:
# _join <delimiter> <list-item>...
#
# Description:
# Print a string containing all <list-item> arguments separated by
# <delimeter>.
#
# Example:
# _join "${_delimeter}" "${_list[@]}"
_join() {
local _delimiter="${1}"
shift
printf "%s" "${1}"
shift
printf "%s" "${@/#/${_delimiter}}" | tr -d '[:space:]'
}
# Usage:
# _blank <argument>
#
# Exit / Error Status:
# 0 (success, true) If <argument> is not present or null.
# 1 (error, false) If <argument> is present and not null.
_blank() {
[[ -z "${1:-}" ]]
}
# Usage:
# _present <argument>
#
# Exit / Error Status:
# 0 (success, true) If <argument> is present and not null.
# 1 (error, false) If <argument> is not present or null.
_present() {
[[ -n "${1:-}" ]]
}
# Usage:
# _interactive_input
#
# Exit / Error Status:
# 0 (success, true) If the current input is interactive (eg, a shell).
# 1 (error, false) If the current input is stdin / piped input.
_interactive_input() {
[[ -t 0 ]]
}
# Usage:
# _piped_input
#
# Exit / Error Status:
# 0 (success, true) If the current input is stdin / piped input.
# 1 (error, false) If the current input is interactive (eg, a shell).
_piped_input() {
! _interactive_input
}
# Usage:
# describe <name> <description>
# describe --get <name>
#
# Options:
# --get Print the description for <name> if one has been set.
describe() {
_debug printf "describe() \${*}: %s\\n" "$@"
[[ -z "${1:-}" ]] && _exit_1 printf "describe(): <name> required.\\n"
if [[ "${1}" == "--get" ]]
then
[[ -z "${2:-}" ]] &&
_exit_1 printf "describe(): <description> required.\\n"
local _name="${2:-}"
local _describe_var="___describe_${_name}"
if [[ -n "${!_describe_var:-}" ]]
then
printf "%s\\n" "${!_describe_var}"
else
printf "No additional information for \`%s\`\\n" "${_name}"
fi
else
if [[ -n "${2:-}" ]]
then
read -r -d '' "___describe_${1}" <<HEREDOC
${2}
HEREDOC
else
read -r -d '' "___describe_${1}" || true
fi
fi
}
# Iterate over options, breaking -ab into -a -b and --foo=bar into --foo bar
# also turns -- into --endopts to avoid issues with things like '-o-', the '-'
# should not indicate the end of options, but be an invalid option (or the
# argument to the option, such as wget -qO-)
# Source:
# https://github.com/e36freak/templates/blob/master/options
unset options
# while the number of arguments is greater than 0
while ((${#}))
do
case "${1}" in
# if option is of type -ab
-[!-]?*)
# loop over each character starting with the second
for ((i=1; i<${#1}; i++))
do
# extract 1 character from position 'i'
c="${1:i:1}"
# add current char to options
options+=("-${c}")
done
;;
# if option is of type --foo=bar, split on first '='
--?*=*)
options+=("${1%%=*}" "${1#*=}")
;;
# end of options, stop breaking them up
--)
options+=(--endopts)
shift
options+=("${@}")
break
;;
# otherwise, nothing special
*)
options+=("${1}")
;;
esac
shift
done
# set new positional parameters to altered options. Set default to blank.
set -- "${options[@]:-}"
unset options
_SUBCOMMAND=""
_SUBCOMMAND_ARGUMENTS=()
_USE_DEBUG=0
_YES=0
while ((${#}))
do
__opt="${1}"
shift
case "${__opt}" in
-h|--help)
_SUBCOMMAND="help"
;;
--version)
_SUBCOMMAND="version"
;;
--debug)
_USE_DEBUG=1
;;
--yes)
_YES=1
;;
*)
# The first non-option argument is assumed to be the subcommand name.
# All subsequent arguments are added to $_SUBCOMMAND_ARGUMENTS.
if [[ -n "${_SUBCOMMAND}" ]]
then
_SUBCOMMAND_ARGUMENTS+=("${__opt}")
else
_SUBCOMMAND="${__opt}"
fi
;;
esac
done
###############################################################################
# Main
###############################################################################
_DEFINED_SUBCOMMANDS=()
_main() {
if [[ -z "${_SUBCOMMAND}" ]]
then
_SUBCOMMAND="${NAG_DEFAULT}"
fi
for __name in $(declare -F)
do
local _function_name
_function_name=$(printf "%s" "${__name}" | awk '{ print $3 }')
if ! { [[ -z "${_function_name:-}" ]] ||
[[ "${_function_name}" =~ ^_(.*) ]] ||
[[ "${_function_name}" == "bats_readlinkf" ]] ||
[[ "${_function_name}" == "describe" ]] ||
[[ "${_function_name}" == "shell_session_update" ]]
}
then
_DEFINED_SUBCOMMANDS+=("${_function_name}")
fi
done
# If our _SUBCOMMAND is defined, execute it. Otherwise, attempt to
# execute _create_alarm. _create_alarm must check its own args, and
# _exit_1 if inappropriate.
if _contains "${_SUBCOMMAND}" "${_DEFINED_SUBCOMMANDS[@]:-}"
then
${_SUBCOMMAND} "${_SUBCOMMAND_ARGUMENTS[@]:-}"
else
"${_FALLBACK_COMMAND_IF_NO_MATCH}" "${_SUBCOMMAND}" "${_SUBCOMMAND_ARGUMENTS[@]:-}"
fi
}
###############################################################################
# Storage
###############################################################################
# Global array holding raw TSV alarm lines (one element per alarm).
_ALARMS=()
# Usage:
# _ensure_nag_dir
#
# Description:
# Create the parent directory for NAG_PATH if it doesn't already exist.
_ensure_nag_dir() {
local _dir
_dir="$(dirname "${NAG_PATH}")"
[[ -d "${_dir}" ]] || mkdir -p "${_dir}"
}
# Usage:
# _acquire_lock
#
# Description:
# Acquire an exclusive lock on ${_LOCKFILE} using flock. Exits with an
# error if the lock cannot be obtained (another instance is running).
_acquire_lock() {
_ensure_nag_dir
exec {_LOCK_FD}>"${_LOCKFILE}"
if ! flock -n "${_LOCK_FD}"
then
_exit_1 printf "Could not acquire lock: %s\\n" "${_LOCKFILE}"
fi
}
# Usage:
# _release_lock
#
# Description:
# Release the exclusive lock previously acquired by _acquire_lock.
_release_lock() {
if [[ -n "${_LOCK_FD:-}" ]]
then
exec {_LOCK_FD}>&-
fi
}
# Usage:
# _read_alarms
#
# Description:
# Read alarms from NAG_PATH into the global _ALARMS array. Each element
# is one raw TSV line. If the file is missing or empty, _ALARMS is set to
# an empty array.
_read_alarms() {
_ALARMS=()
if [[ -f "${NAG_PATH}" ]] && [[ -s "${NAG_PATH}" ]]
then
local _line
while IFS= read -r _line || [[ -n "${_line}" ]]
do
[[ -n "${_line}" ]] && _ALARMS+=("${_line}")
done < "${NAG_PATH}" || true
fi
}
# Usage:
# _write_alarms
#
# Description:
# Write the _ALARMS array atomically to NAG_PATH. Writes to a temporary
# file first, then moves it over the original. If _ALARMS is empty, an
# empty file is written.
_write_alarms() {
_ensure_nag_dir
local _tmp
_tmp="$(mktemp "${NAG_PATH}.XXXXXX")"
if (( ${#_ALARMS[@]} > 0 ))
then
printf "%s\n" "${_ALARMS[@]}" > "${_tmp}"
else
: > "${_tmp}"
fi
mv -f "${_tmp}" "${NAG_PATH}"
}
# Usage:
# _next_id
#
# Description:
# Find the next available alarm ID (the first gap in the existing ID
# sequence starting from 1). Prints the ID to stdout.
_next_id() {
_read_alarms
if (( ${#_ALARMS[@]} == 0 ))
then
printf "%s\n" "1"
return
fi
local _ids=()
local _line
for _line in "${_ALARMS[@]}"
do
_ids+=("$(printf "%s" "${_line}" | cut -f1)")
done
# Sort numerically
local _sorted
_sorted="$(printf "%s\n" "${_ids[@]}" | sort -n)"
local _expected=1
local _id
while IFS= read -r _id
do
if (( _id != _expected ))
then
printf "%s\n" "${_expected}"
return
fi
_expected=$((_expected + 1))
done <<< "${_sorted}"
printf "%s\n" "${_expected}"
}
# Usage:
# _get_alarm_field <line> <field-number>
#
# Description:
# Extract a field from a TSV alarm line.
# Fields: 1=id, 2=timestamp, 3=rule, 4=message.
# For field 4 (message), returns everything after the 3rd tab.
_get_alarm_field() {
local _line="${1}"
local _field="${2}"
if (( _field == 4 ))
then
printf "%s" "${_line}" | cut -f4-
else
printf "%s" "${_line}" | cut -f"${_field}"
fi
}
# Usage:
# _format_time <timestamp>
#
# Description:
# Format a unix timestamp for display.
# Time: dots for minutes (3.30pm), omit .00 (3pm).
# Date: Today / Tomorrow / day-of-week within 6 days /
# Next <day> within 13 days / Mon Dec 25 beyond that.
_format_time() {
local _timestamp="${1}"
# Format time: drop .00, use dots for minutes.
local _hour _minute _ampm _time_fmt
_hour="$(date -d "@${_timestamp}" "+%-I")"
_minute="$(date -d "@${_timestamp}" "+%M")"
_ampm="$(date -d "@${_timestamp}" "+%p" | tr '[:upper:]' '[:lower:]')"
if [[ "${_minute}" == "00" ]]
then
_time_fmt="${_hour}${_ampm}"
else
_time_fmt="${_hour}.${_minute}${_ampm}"
fi
# Format date prefix.
local _alarm_date _today _days_away
_alarm_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"
_today="$(date +%Y-%m-%d)"
_days_away=$(( ( $(date -d "${_alarm_date}" +%s) - $(date -d "${_today}" +%s) ) / 86400 ))
local _date_prefix
if (( _days_away == 0 ))
then
_date_prefix="Today"
elif (( _days_away == 1 ))
then
_date_prefix="Tomorrow"
elif (( _days_away <= 6 ))
then
_date_prefix="This $(date -d "@${_timestamp}" "+%A")"
elif (( _days_away <= 13 ))
then
_date_prefix="Next $(date -d "@${_timestamp}" "+%A")"
else
_date_prefix="$(date -d "@${_timestamp}" "+%a %b %-d")"
fi
printf "%s, %s" "${_date_prefix}" "${_time_fmt}"
}
# Usage:
# _validate_rules <rules_string>
#
# Description:
# Validate a comma-separated list of repeat rules. Exits with error
# if any rule is invalid.
_validate_rules() {
local _rules_str="${1:-}"
[[ -n "${_rules_str}" ]] || _exit_1 printf "No rules specified.\\n"
local IFS=","
local _rule
for _rule in ${_rules_str}
do
if ! _contains "${_rule}" "${_VALID_RULES[@]}"
then
_exit_1 printf "Invalid rule: %s\\n" "${_rule}"
fi
done
}
# Usage:
# _next_occurrence <rules> <timestamp>
#
# Description:
# Compute the next occurrence for a repeating alarm. For comma-separated
# rules, returns the earliest next occurrence across all rules.
_next_occurrence() {
local _rules_str="${1}"
local _timestamp="${2}"
local _earliest=""
local IFS=","
local _rule
for _rule in ${_rules_str}
do
IFS=$'\n\t'
local _next
_next="$(_next_for_rule "${_rule}" "${_timestamp}")"
if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
then
_earliest="${_next}"
fi
done
IFS=$'\n\t'
printf "%s" "${_earliest}"
}
# Usage:
# _next_for_rule <rule> <timestamp>
#
# Description:
# Compute the next occurrence for a single repeat rule.
_next_for_rule() {
local _rule="${1}"
local _timestamp="${2}"
local _time_of_day
_time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
case "${_rule}" in
hour)
printf "%s" "$((_timestamp + 3600))"
;;
day)
date -d "$(date -d "@${_timestamp}" +%Y-%m-%d) + 1 day ${_time_of_day}" +%s
;;
weekday)
_next_matching_day "${_timestamp}" "${_time_of_day}" "1 2 3 4 5"
;;
weekend)
_next_matching_day "${_timestamp}" "${_time_of_day}" "6 7"
;;
monday) _next_matching_day "${_timestamp}" "${_time_of_day}" "1" ;;
tuesday) _next_matching_day "${_timestamp}" "${_time_of_day}" "2" ;;
wednesday) _next_matching_day "${_timestamp}" "${_time_of_day}" "3" ;;
thursday) _next_matching_day "${_timestamp}" "${_time_of_day}" "4" ;;
friday) _next_matching_day "${_timestamp}" "${_time_of_day}" "5" ;;
saturday) _next_matching_day "${_timestamp}" "${_time_of_day}" "6" ;;
sunday) _next_matching_day "${_timestamp}" "${_time_of_day}" "7" ;;
month)
_next_month "${_timestamp}" "${_time_of_day}"
;;
year)
_next_year "${_timestamp}" "${_time_of_day}"
;;
esac
}
# Usage:
# _next_matching_day <timestamp> <time_of_day> <target_days>
#
# Description:
# Walk forward from tomorrow until a day-of-week matches one of the
# target days. Days are space-separated, 1=Mon 7=Sun (date +%u format).
_next_matching_day() {
local _timestamp="${1}"
local _time_of_day="${2}"
local _targets="${3}"
local _base_date
_base_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"
local _i
for _i in 1 2 3 4 5 6 7
do
local _candidate_ts _candidate_dow
_candidate_ts="$(date -d "${_base_date} + ${_i} day ${_time_of_day}" +%s)"
_candidate_dow="$(date -d "${_base_date} + ${_i} day" +%u)"
if [[ " ${_targets} " == *" ${_candidate_dow} "* ]]
then
printf "%s" "${_candidate_ts}"
return 0
fi
done
}
# Usage:
# _next_month <timestamp> <time_of_day>
#
# Description:
# Same day-of-month next month, same time. Clamps to last day of
# month if the day doesn't exist (e.g. 31st in a 30-day month).
_next_month() {
local _timestamp="${1}"
local _time_of_day="${2}"
local _day _month _year
_day="$(date -d "@${_timestamp}" +%-d)"
_month="$(date -d "@${_timestamp}" +%-m)"
_year="$(date -d "@${_timestamp}" +%Y)"
_month=$((_month + 1))
if (( _month > 12 ))
then
_month=1
_year=$((_year + 1))
fi
local _last_day
_last_day="$(date -d "${_year}-$(printf "%02d" "${_month}")-01 + 1 month - 1 day" +%-d)"
if (( _day > _last_day ))
then
_day="${_last_day}"
fi
date -d "$(printf "%04d-%02d-%02d %s" "${_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s
}
# Usage:
# _next_year <timestamp> <time_of_day>
#
# Description:
# Same month and day next year, same time. Feb 29 in a non-leap year
# clamps to Feb 28.
_next_year() {
local _timestamp="${1}"
local _time_of_day="${2}"
local _day _month _year
_day="$(date -d "@${_timestamp}" +%-d)"
_month="$(date -d "@${_timestamp}" +%-m)"
_year="$(date -d "@${_timestamp}" +%Y)"
_year=$((_year + 1))
if ! date -d "$(printf "%04d-%02d-%02d" "${_year}" "${_month}" "${_day}")" &>/dev/null
then
local _last_day
_last_day="$(date -d "${_year}-$(printf "%02d" "${_month}")-01 + 1 month - 1 day" +%-d)"
_day="${_last_day}"
fi
date -d "$(printf "%04d-%02d-%02d %s" "${_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s
}
# Usage:
# _first_occurrence <rules> <timestamp>
#
# Description:
# Compute the first valid occurrence for a repeating alarm. If the
# given timestamp already falls on a matching day, use it. Otherwise
# find the next matching day at the same time-of-day.
_first_occurrence() {
local _rules_str="${1}"
local _timestamp="${2}"
if _timestamp_matches_rule "${_rules_str}" "${_timestamp}"
then
printf "%s" "${_timestamp}"
else
# Use a timestamp from yesterday so _next_occurrence walks from today.
local _yesterday=$((_timestamp - 86400))
local _time_of_day
_time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
local _earliest=""
local IFS=","
local _rule
for _rule in ${_rules_str}
do
IFS=$'\n\t'
local _next
_next="$(_next_for_rule "${_rule}" "${_yesterday}")"
# Ensure we don't go before the original timestamp's time today.
if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
then
_earliest="${_next}"
fi
done
IFS=$'\n\t'
printf "%s" "${_earliest}"
fi
}
# Usage:
# _timestamp_matches_rule <rules> <timestamp>
#
# Description:
# Check if a timestamp falls on a day that matches any of the given rules.
_timestamp_matches_rule() {
local _rules_str="${1}"
local _timestamp="${2}"
local _dow
_dow="$(date -d "@${_timestamp}" +%u)" # 1=Mon 7=Sun
local IFS=","
local _rule
for _rule in ${_rules_str}
do
case "${_rule}" in
hour|day) IFS=$'\n\t'; return 0 ;;
weekday) [[ " 1 2 3 4 5 " == *" ${_dow} "* ]] && { IFS=$'\n\t'; return 0; } ;;
weekend) [[ " 6 7 " == *" ${_dow} "* ]] && { IFS=$'\n\t'; return 0; } ;;
monday) [[ "${_dow}" == "1" ]] && { IFS=$'\n\t'; return 0; } ;;
tuesday) [[ "${_dow}" == "2" ]] && { IFS=$'\n\t'; return 0; } ;;
wednesday) [[ "${_dow}" == "3" ]] && { IFS=$'\n\t'; return 0; } ;;
thursday) [[ "${_dow}" == "4" ]] && { IFS=$'\n\t'; return 0; } ;;
friday) [[ "${_dow}" == "5" ]] && { IFS=$'\n\t'; return 0; } ;;
saturday) [[ "${_dow}" == "6" ]] && { IFS=$'\n\t'; return 0; } ;;
sunday) [[ "${_dow}" == "7" ]] && { IFS=$'\n\t'; return 0; } ;;
month|year) IFS=$'\n\t'; return 0 ;;
esac
done
IFS=$'\n\t'
return 1
}
# Usage:
# _parse_time <time-string>
#
# Description:
# Parse a human-readable time string via `date -d` and print a unix
# timestamp. If the time is earlier today, roll forward to tomorrow.
# If the date is explicitly in the past (a previous day), error.
_parse_time() {
local _time_str="${1:-}"
[[ -n "${_time_str}" ]] || _exit_1 printf "No time specified.\\n"
local _timestamp
_timestamp="$(date -d "${_time_str}" +%s 2>/dev/null)" ||
_exit_1 printf "Invalid time: %s\\n" "${_time_str}"
local _now
_now="$(date +%s)"
if [[ "${_timestamp}" -le "${_now}" ]]
then
local _parsed_date _today
_parsed_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"
_today="$(date +%Y-%m-%d)"
if [[ "${_parsed_date}" != "${_today}" ]]
then
_exit_1 printf "Time is in the past: %s.\\n" "${_time_str}"
fi
# Today but already passed: roll to tomorrow same time.
local _time_of_day
_time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
_timestamp="$(date -d "tomorrow ${_time_of_day}" +%s)" ||
_exit_1 printf "Could not compute next day for: %s\\n" "${_time_str}"
fi
printf "%s" "${_timestamp}"
}
# Usage:
# _prompt_cron
#
# Description:
# Check whether a cron entry for `nag check` exists. If not, prompt the
# user to install one (or install automatically when --yes is set).
_prompt_cron() {
if ! _command_exists crontab
then
_warn printf "crontab not found. Without a cron daemon, alarms will not trigger.\\n"
return 0
fi
if crontab -l 2>/dev/null | grep -qF "${_ME} check"
then
return 0
fi
if ((_YES))
then
_install_cron
return 0
fi
if _interactive_input
then
printf "A cron job for '%s check' is needed to trigger timers. Add one? [Y/n] " "${_ME}"
local _reply
read -r _reply
case "${_reply}" in
[nN]*)
return 0
;;
*)
_install_cron
;;
esac
fi
}
# Usage:
# _install_cron
#
# Description:
# Append a `* * * * * nag check` entry to the current user's crontab.
_install_cron() {
local _nag_path
_nag_path="$(command -v nag 2>/dev/null || printf "%s" "$(cd "$(dirname "${0}")" && pwd)/nag")"
(crontab -l 2>/dev/null; printf "* * * * * %s check\\n" "${_nag_path}") | crontab -
printf "cron entry added.\\n"
}
###############################################################################
# Subcommands
###############################################################################
# help ########################################################################
describe "help" <<HEREDOC
Usage:
${_ME} help [<subcommand>]
Description:
Display help information for ${_ME} or a specified subcommand.
HEREDOC
help() {
if [[ -n "${1:-}" ]]
then
describe --get "${1}"
else
cat <<HEREDOC
nag
Version: ${_VERSION}
Usage:
${_ME} <time> <message...> one-shot alarm
${_ME} every <rules> <time> <message...> repeating alarm
${_ME} stop <id> delete alarm
${_ME} skip <id> skip next occurrence
${_ME} check fire expired alarms (cron)
${_ME} help [<subcommand>] show help
${_ME} list all alarms
Options:
--yes Skip all prompts.
Help:
${_ME} help [<subcommand>]
HEREDOC
fi
}
# version #####################################################################
describe "version" <<HEREDOC
Usage:
${_ME} ( version | --version )
Description:
Display the current program version.
HEREDOC
version() {
printf "%s\\n" "${_VERSION}"
}
# list ########################################################################
describe "list" <<HEREDOC
Usage:
${_ME} list
Description:
List all alarms. This is the default when no subcommand is given.
HEREDOC
list() {
if [[ ! -f "${NAG_PATH}" ]] || [[ ! -s "${NAG_PATH}" ]]
then
printf "Nothing to nag about.\\n"
return 0
fi
_read_alarms
if (( ${#_ALARMS[@]} == 0 ))
then
printf "Nothing to nag about.\\n"
return 0
fi
# Sort alarms by timestamp (field 2).
local -a _sorted
IFS=$'\n' _sorted=($(printf "%s\n" "${_ALARMS[@]}" | sort -t$'\t' -k2 -n))
IFS=$'\n\t'
local _line
for _line in "${_sorted[@]}"
do
[[ -n "${_line}" ]] || continue
local _id _timestamp _rule _message _human_time _rule_display
_id="$(_get_alarm_field "${_line}" 1)"
_timestamp="$(_get_alarm_field "${_line}" 2)"
_rule="$(_get_alarm_field "${_line}" 3)"
_message="$(_get_alarm_field "${_line}" 4)"
_human_time="$(_format_time "${_timestamp}")"
if [[ -n "${_rule}" ]]
then
_rule_display=" (${_rule//,/, })"
else
_rule_display=""
fi
printf "[%s] %s%s — %s\\n" "${_id}" "${_human_time}" "${_rule_display}" "${_message}"
done
}
# stop ########################################################################
describe "stop" <<HEREDOC
Usage:
${_ME} stop <id>
Description:
Stop an alarm by ID.
HEREDOC
stop() {
local _target_id="${1:-}"
[[ -n "${_target_id}" ]] || _exit_1 printf "Usage: %s stop <id>\\n" "${_ME}"
_acquire_lock
_read_alarms
local -a _new_alarms=()
local _found=0
local _line
for _line in "${_ALARMS[@]:-}"
do
[[ -n "${_line}" ]] || continue
local _id
_id="$(_get_alarm_field "${_line}" 1)"
if [[ "${_id}" == "${_target_id}" ]]
then
_found=1
else
_new_alarms+=("${_line}")
fi
done
if [[ "${_found}" -eq 0 ]]
then
_release_lock
_exit_1 printf "No alarm with ID %s.\\n" "${_target_id}"
fi
if [[ "${#_new_alarms[@]}" -eq 0 ]]
then
_ALARMS=()
: > "${NAG_PATH}"
else
_ALARMS=("${_new_alarms[@]}")
_write_alarms
fi
_release_lock
printf "Stopped alarm %s.\\n" "${_target_id}"
}
# at ##########################################################################
describe "at" <<HEREDOC
Usage:
${_ME} [at] <time> <message...>
Description:
Create a one-shot alarm. The "at" is optional. If the first argument
doesn't match any other subcommand of ${_ME}, it'll fallback to "at".
Examples:
${_ME} 3pm take a break
${_ME} at 3pm take a break
${_ME} "tomorrow 9am" dentist appointment
HEREDOC
at() {
local _time_str="${1:-}"
shift || true
local _message
IFS=' ' _message="${*:-}"
IFS=$'\n\t'
[[ -n "${_time_str}" ]] || _exit_1 printf "Usage: %s [at] <time> <message>\\n" "${_ME}"
[[ -n "${_message}" ]] || _exit_1 printf "No message specified.\\n"
local _timestamp
_timestamp="$(_parse_time "${_time_str}")"
_acquire_lock
local _id
_id="$(_next_id)"
# _next_id calls _read_alarms in a subshell, so _ALARMS isn't populated here.
_read_alarms
_ALARMS+=("$(printf "%s\t%s\t\t%s" "${_id}" "${_timestamp}" "${_message}")")
_write_alarms
_release_lock
_prompt_cron
local _human_time
_human_time="$(_format_time "${_timestamp}")"
printf "[%s] %s — %s\\n" "${_id}" "${_human_time}" "${_message}"
}
# every ########################################################################
describe "every" <<HEREDOC
Usage:
${_ME} every <rules> <time> <message...>
Description:
Create a repeating alarm. Rules are a comma-separated list of repeat
schedules: hour, day, weekday, weekend, monday-sunday, month, year.
Examples:
${_ME} every weekday "tomorrow 9am" standup meeting
${_ME} every "tuesday,thursday" "tomorrow 3pm" team sync
HEREDOC
every() {
local _rules_str="${1:-}"
shift || true
local _time_str="${1:-}"
shift || true
local _message
IFS=' ' _message="${*:-}"
IFS=$'\n\t'
[[ -n "${_rules_str}" ]] || _exit_1 printf "Usage: %s every <rules> <time> <message...>\\n" "${_ME}"
[[ -n "${_time_str}" ]] || _exit_1 printf "Usage: %s every <rules> <time> <message...>\\n" "${_ME}"
[[ -n "${_message}" ]] || _exit_1 printf "No message specified.\\n"
_validate_rules "${_rules_str}"
local _timestamp
_timestamp="$(_parse_time "${_time_str}")"
# Snap to the first occurrence that matches the rule.
_timestamp="$(_first_occurrence "${_rules_str}" "${_timestamp}")"
_acquire_lock
local _id
_id="$(_next_id)"
# _next_id calls _read_alarms in a subshell, so _ALARMS isn't populated here.
_read_alarms
_ALARMS+=("$(printf "%s\t%s\t%s\t%s" "${_id}" "${_timestamp}" "${_rules_str}" "${_message}")")
_write_alarms
_release_lock
_prompt_cron
local _human_time
_human_time="$(_format_time "${_timestamp}")"
printf "[%s] %s (%s) — %s\\n" "${_id}" "${_human_time}" "${_rules_str//,/, }" "${_message}"
}
# _main must be called after everything has been defined.
_main