1428 lines
36 KiB
Bash
Executable file
1428 lines
36 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_DIR="${NAG_DIR:-${HOME}/.local/share/nag}"
|
|
_ALARMS_FILE="${NAG_DIR}/alarms"
|
|
_LOCKFILE="${NAG_DIR}/alarms.lock"
|
|
|
|
# The command nag runs to execute its notifications.
|
|
NAG_CMD="${NAG_CMD:-notify-send}"
|
|
|
|
# Sound file to play when an alarm fires. Empty or missing file = no sound.
|
|
NAG_SOUND="${NAG_SOUND-/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga}"
|
|
_MUTED_FILE="${NAG_DIR}/muted"
|
|
|
|
# The default subcommand if no args are passed.
|
|
NAG_DEFAULT="${NAG_DEFAULT:-list}"
|
|
|
|
# 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:
|
|
# _play_sound
|
|
#
|
|
# Description:
|
|
# Play the alarm sound if NAG_SOUND exists and not muted.
|
|
_play_sound() {
|
|
[[ -n "${NAG_SOUND}" ]] && [[ -f "${NAG_SOUND}" ]] && [[ ! -f "${_MUTED_FILE}" ]] || return 0
|
|
|
|
if _command_exists pw-play; then
|
|
pw-play "${NAG_SOUND}"
|
|
elif _command_exists paplay; then
|
|
paplay "${NAG_SOUND}"
|
|
elif _command_exists aplay; then
|
|
aplay "${NAG_SOUND}"
|
|
fi
|
|
}
|
|
|
|
# Usage:
|
|
# _ensure_nag_dir
|
|
#
|
|
# Description:
|
|
# Create the nag directory if it doesn't already exist.
|
|
_ensure_nag_dir() {
|
|
[[ -d "${NAG_DIR}" ]] || mkdir -p "${NAG_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 _ALARMS_FILE 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 "${_ALARMS_FILE}" ]] && [[ -s "${_ALARMS_FILE}" ]]
|
|
then
|
|
local _line
|
|
while IFS= read -r _line || [[ -n "${_line}" ]]
|
|
do
|
|
[[ -n "${_line}" ]] && _ALARMS+=("${_line}")
|
|
done < "${_ALARMS_FILE}" || true
|
|
fi
|
|
}
|
|
|
|
# Usage:
|
|
# _write_alarms
|
|
#
|
|
# Description:
|
|
# Write the _ALARMS array atomically to _ALARMS_FILE. 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 "${_ALARMS_FILE}.XXXXXX")"
|
|
if (( ${#_ALARMS[@]} > 0 ))
|
|
then
|
|
printf "%s\n" "${_ALARMS[@]}" > "${_tmp}"
|
|
else
|
|
: > "${_tmp}"
|
|
fi
|
|
mv -f "${_tmp}" "${_ALARMS_FILE}"
|
|
}
|
|
|
|
# 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:
|
|
# _normalise_rule <rule>
|
|
#
|
|
# Description:
|
|
# Map a rule alias to its canonical short form. Prints the canonical
|
|
# form to stdout. Input is expected to be lowercase.
|
|
_normalise_rule() {
|
|
case "${1}" in
|
|
h|hr|hour|hours|hourly) printf "hour" ;;
|
|
d|day|days|daily) printf "day" ;;
|
|
week|weekly) printf "week" ;;
|
|
weekday|weekdays) printf "weekday" ;;
|
|
weekend|weekends) printf "weekend" ;;
|
|
mon|monday|mondays) printf "mon" ;;
|
|
tue|tuesday|tuesdays) printf "tue" ;;
|
|
wed|wednesday|wednesdays) printf "wed" ;;
|
|
thurs|thursday|thursdays) printf "thu" ;;
|
|
fri|friday|fridays) printf "fri" ;;
|
|
sat|saturday|saturdays) printf "sat" ;;
|
|
sun|sunday|sundays) printf "sun" ;;
|
|
month|months|monthly) printf "month" ;;
|
|
year|years|yearly) printf "year" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# Usage:
|
|
# _validate_and_normalise_rules <rules_string>
|
|
#
|
|
# Description:
|
|
# Validate and normalise a comma-separated list of repeat rules.
|
|
# Input is case-insensitive. Prints the normalised comma-separated
|
|
# rules to stdout. Exits with error if any rule is invalid.
|
|
_validate_and_normalise_rules() {
|
|
local _rules_str="${1:-}"
|
|
[[ -n "${_rules_str}" ]] || _exit_1 printf "No rules specified.\\n"
|
|
|
|
# Lowercase the input.
|
|
_rules_str="$(printf "%s" "${_rules_str}" | tr '[:upper:]' '[:lower:]')"
|
|
|
|
local _normalised=()
|
|
local IFS=","
|
|
local _rule
|
|
for _rule in ${_rules_str}
|
|
do
|
|
local _canon
|
|
_canon="$(_normalise_rule "${_rule}")" ||
|
|
_exit_1 printf "Invalid rule: %s.\\n" "${_rule}"
|
|
_normalised+=("${_canon}")
|
|
done
|
|
IFS=$'\n\t'
|
|
|
|
# Join with commas.
|
|
local _result="${_normalised[0]}"
|
|
local _i
|
|
for (( _i=1; _i<${#_normalised[@]}; _i++ ))
|
|
do
|
|
_result="${_result},${_normalised[${_i}]}"
|
|
done
|
|
|
|
printf "%s" "${_result}"
|
|
}
|
|
|
|
# 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
|
|
local _next
|
|
_next="$(_next_for_rule "${_rule}" "${_timestamp}")"
|
|
if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
|
|
then
|
|
_earliest="${_next}"
|
|
fi
|
|
done
|
|
|
|
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 ;;
|
|
week) _next_matching_day "${_timestamp}" "${_time_of_day}" "$(date -d "@${_timestamp}" +%u)" ;;
|
|
weekday) _next_matching_day "${_timestamp}" "${_time_of_day}" "1 2 3 4 5" ;;
|
|
weekend) _next_matching_day "${_timestamp}" "${_time_of_day}" "6 7" ;;
|
|
mon) _next_matching_day "${_timestamp}" "${_time_of_day}" "1" ;;
|
|
tue) _next_matching_day "${_timestamp}" "${_time_of_day}" "2" ;;
|
|
wed) _next_matching_day "${_timestamp}" "${_time_of_day}" "3" ;;
|
|
thu) _next_matching_day "${_timestamp}" "${_time_of_day}" "4" ;;
|
|
fri) _next_matching_day "${_timestamp}" "${_time_of_day}" "5" ;;
|
|
sat) _next_matching_day "${_timestamp}" "${_time_of_day}" "6" ;;
|
|
sun) _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
|
|
local _next
|
|
_next="$(_next_for_rule "${_rule}" "${_yesterday}")"
|
|
if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
|
|
then
|
|
_earliest="${_next}"
|
|
fi
|
|
done
|
|
|
|
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|week|month|year) return 0 ;;
|
|
weekday) [[ " 1 2 3 4 5 " == *" ${_dow} "* ]] && return 0 ;;
|
|
weekend) [[ " 6 7 " == *" ${_dow} "* ]] && return 0 ;;
|
|
mon) [[ "${_dow}" == "1" ]] && return 0 ;;
|
|
tue) [[ "${_dow}" == "2" ]] && return 0 ;;
|
|
wed) [[ "${_dow}" == "3" ]] && return 0 ;;
|
|
thu) [[ "${_dow}" == "4" ]] && return 0 ;;
|
|
fri) [[ "${_dow}" == "5" ]] && return 0 ;;
|
|
sat) [[ "${_dow}" == "6" ]] && return 0 ;;
|
|
sun) [[ "${_dow}" == "7" ]] && return 0 ;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Usage:
|
|
# _parse_time <time-string>
|
|
#
|
|
# Description:
|
|
# Parse a human-readable time string via `date -d` and print a unix
|
|
# timestamp. If the result is in the past, roll forward to the next
|
|
# occurrence. Rejects explicitly backward terms like "yesterday"/"ago".
|
|
_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. See 'date -d' for accepted formats.\\n" "${_time_str}"
|
|
|
|
local _lower
|
|
_lower="$(printf "%s" "${_time_str}" | tr '[:upper:]' '[:lower:]')"
|
|
if [[ "${_lower}" == *"yesterday"* ]] || [[ "${_lower}" == *" ago"* ]] || [[ "${_lower}" == "last "* ]]
|
|
then
|
|
_exit_1 printf "Time is in the past: %s.\\n" "${_time_str}"
|
|
fi
|
|
|
|
local _now
|
|
_now="$(date +%s)"
|
|
|
|
if (( _timestamp <= _now ))
|
|
then
|
|
local _time_of_day _month _day
|
|
_time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
|
|
_month="$(date -d "@${_timestamp}" +%-m)"
|
|
_day="$(date -d "@${_timestamp}" +%-d)"
|
|
|
|
local _today_date _parsed_date
|
|
_today_date="$(date +%Y-%m-%d)"
|
|
_parsed_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"
|
|
|
|
if [[ "${_parsed_date}" == "${_today_date}" ]]
|
|
then
|
|
_timestamp="$(date -d "tomorrow ${_time_of_day}" +%s)"
|
|
else
|
|
local _next_year
|
|
_next_year="$(date +%Y)"
|
|
local _this_year_ts
|
|
_this_year_ts="$(date -d "$(printf "%04d-%02d-%02d %s" "${_next_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s 2>/dev/null)" || _this_year_ts=0
|
|
if (( _this_year_ts > _now ))
|
|
then
|
|
_timestamp="${_this_year_ts}"
|
|
else
|
|
_next_year=$((_next_year + 1))
|
|
_timestamp="$(date -d "$(printf "%04d-%02d-%02d %s" "${_next_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s)"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
printf "%s" "${_timestamp}"
|
|
}
|
|
|
|
# Usage:
|
|
# _prompt_timer
|
|
#
|
|
# Description:
|
|
# Check whether a systemd user timer for `nag check` is active.
|
|
# If not, prompt the user to install one (or install automatically
|
|
# when --yes is set). Falls back to cron if systemd is unavailable.
|
|
_prompt_timer() {
|
|
# Already running?
|
|
if systemctl --user is-active nag.timer &>/dev/null
|
|
then
|
|
return 0
|
|
fi
|
|
|
|
# Cron fallback: already installed?
|
|
if crontab -l 2>/dev/null | grep -qF "${_ME} check"
|
|
then
|
|
return 0
|
|
fi
|
|
|
|
if ((_YES))
|
|
then
|
|
_install_timer
|
|
return 0
|
|
fi
|
|
|
|
if _interactive_input
|
|
then
|
|
printf "A timer for '%s check' is needed to trigger alarms. Install one? [Y/n] " "${_ME}"
|
|
local _reply
|
|
read -r _reply
|
|
case "${_reply}" in
|
|
[nN]*)
|
|
return 0
|
|
;;
|
|
*)
|
|
_install_timer
|
|
;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
# Usage:
|
|
# _install_timer
|
|
#
|
|
# Description:
|
|
# Install a systemd user timer that runs `nag check` every 15 seconds.
|
|
# Falls back to cron if systemctl is unavailable.
|
|
_install_timer() {
|
|
local _nag_path
|
|
_nag_path="$(command -v nag 2>/dev/null || printf "%s" "$(cd "$(dirname "${0}")" && pwd)/nag")"
|
|
|
|
if _command_exists systemctl
|
|
then
|
|
local _unit_dir="${HOME}/.config/systemd/user"
|
|
mkdir -p "${_unit_dir}"
|
|
|
|
cat > "${_unit_dir}/nag.service" <<UNIT
|
|
[Unit]
|
|
Description=nag alarm check
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=${_nag_path} check
|
|
UNIT
|
|
|
|
cat > "${_unit_dir}/nag.timer" <<UNIT
|
|
[Unit]
|
|
Description=Run nag check every 15 seconds
|
|
|
|
[Timer]
|
|
OnBootSec=15s
|
|
OnUnitActiveSec=15s
|
|
AccuracySec=1s
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
UNIT
|
|
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now nag.timer
|
|
printf "Systemd timer installed (every 15s).\\n"
|
|
elif _command_exists crontab
|
|
then
|
|
(crontab -l 2>/dev/null; printf "* * * * * %s check\\n" "${_nag_path}") | crontab -
|
|
printf "Cron entry added (every 60s).\\n"
|
|
else
|
|
_warn printf "Neither systemctl nor crontab found. Alarms will not trigger automatically.\\n"
|
|
fi
|
|
}
|
|
|
|
###############################################################################
|
|
# 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 "${_ALARMS_FILE}" ]] || [[ ! -s "${_ALARMS_FILE}" ]]
|
|
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=()
|
|
: > "${_ALARMS_FILE}"
|
|
else
|
|
_ALARMS=("${_new_alarms[@]}")
|
|
_write_alarms
|
|
fi
|
|
_release_lock
|
|
|
|
printf "Stopped alarm %s.\\n" "${_target_id}"
|
|
}
|
|
|
|
# skip ########################################################################
|
|
|
|
describe "skip" <<HEREDOC
|
|
Usage:
|
|
${_ME} skip <id>
|
|
|
|
Description:
|
|
Skip the next occurrence of a repeating alarm (reschedule without firing).
|
|
For one-shot alarms, this deletes them.
|
|
HEREDOC
|
|
skip() {
|
|
local _target_id="${1:-}"
|
|
[[ -n "${_target_id}" ]] || _exit_1 printf "Usage: %s skip <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 _timestamp _rule _message
|
|
_id="$(_get_alarm_field "${_line}" 1)"
|
|
_timestamp="$(_get_alarm_field "${_line}" 2)"
|
|
_rule="$(_get_alarm_field "${_line}" 3)"
|
|
_message="$(_get_alarm_field "${_line}" 4)"
|
|
|
|
if [[ "${_id}" == "${_target_id}" ]]
|
|
then
|
|
_found=1
|
|
if [[ -n "${_rule}" ]]
|
|
then
|
|
local _next_ts _human_time
|
|
_next_ts="$(_next_occurrence "${_rule}" "${_timestamp}")"
|
|
_new_alarms+=("$(printf "%s\t%s\t%s\t%s" "${_id}" "${_next_ts}" "${_rule}" "${_message}")")
|
|
_human_time="$(_format_time "${_next_ts}")"
|
|
printf "Skipped. Next: %s\\n" "${_human_time}"
|
|
else
|
|
printf "Stopped alarm %s.\\n" "${_id}"
|
|
fi
|
|
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=()
|
|
: > "${_ALARMS_FILE}"
|
|
else
|
|
_ALARMS=("${_new_alarms[@]}")
|
|
_write_alarms
|
|
fi
|
|
_release_lock
|
|
}
|
|
|
|
# check #######################################################################
|
|
|
|
describe "check" <<HEREDOC
|
|
Usage:
|
|
${_ME} check
|
|
|
|
Description:
|
|
Fire expired alarms. Intended to be run by cron every minute.
|
|
Repeating alarms are rescheduled. One-shot alarms are removed.
|
|
HEREDOC
|
|
check() {
|
|
[[ -f "${_ALARMS_FILE}" ]] || return 0
|
|
|
|
_acquire_lock
|
|
_read_alarms
|
|
|
|
local -a _new_alarms=()
|
|
local _now
|
|
_now="$(date +%s)"
|
|
local _line
|
|
|
|
for _line in "${_ALARMS[@]:-}"
|
|
do
|
|
[[ -n "${_line}" ]] || continue
|
|
|
|
local _id _timestamp _rule _message
|
|
_id="$(_get_alarm_field "${_line}" 1)"
|
|
_timestamp="$(_get_alarm_field "${_line}" 2)"
|
|
_rule="$(_get_alarm_field "${_line}" 3)"
|
|
_message="$(_get_alarm_field "${_line}" 4)"
|
|
|
|
if (( _timestamp <= _now ))
|
|
then
|
|
local _age=$(( _now - _timestamp ))
|
|
local _should_fire=0
|
|
|
|
if (( _age <= 900 ))
|
|
then
|
|
# Within 15 minutes, fire.
|
|
_should_fire=1
|
|
elif [[ "${_rule}" == "year" ]] && \
|
|
[[ "$(date -d "@${_timestamp}" +%Y-%m-%d)" == "$(date +%Y-%m-%d)" ]]
|
|
then
|
|
# Yearly alarm, still the same day.
|
|
_should_fire=1
|
|
fi
|
|
|
|
if (( _should_fire ))
|
|
then
|
|
${NAG_CMD} "nag" "${_message}" || _warn printf "Failed to notify: %s\\n" "${_message}"
|
|
_play_sound
|
|
fi
|
|
|
|
if [[ -n "${_rule}" ]]
|
|
then
|
|
# Repeating: reschedule.
|
|
local _next_ts
|
|
_next_ts="$(_next_occurrence "${_rule}" "${_timestamp}")"
|
|
_new_alarms+=("$(printf "%s\t%s\t%s\t%s" "${_id}" "${_next_ts}" "${_rule}" "${_message}")")
|
|
fi
|
|
# One-shot: drop it (don't add to _new_alarms).
|
|
else
|
|
# Not expired — keep it.
|
|
_new_alarms+=("${_line}")
|
|
fi
|
|
done
|
|
|
|
if [[ "${#_new_alarms[@]}" -eq 0 ]]
|
|
then
|
|
_ALARMS=()
|
|
: > "${_ALARMS_FILE}"
|
|
else
|
|
_ALARMS=("${_new_alarms[@]}")
|
|
_write_alarms
|
|
fi
|
|
_release_lock
|
|
}
|
|
|
|
# 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_timer
|
|
|
|
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"
|
|
|
|
_rules_str="$(_validate_and_normalise_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_timer
|
|
|
|
local _human_time
|
|
_human_time="$(_format_time "${_timestamp}")"
|
|
printf "[%s] %s (%s) — %s\\n" "${_id}" "${_human_time}" "${_rules_str//,/, }" "${_message}"
|
|
}
|
|
|
|
# mute ########################################################################
|
|
|
|
describe "mute" <<HEREDOC
|
|
Usage:
|
|
${_ME} mute
|
|
|
|
Description:
|
|
Mute alarm sounds. Notifications still fire, but no sound is played.
|
|
HEREDOC
|
|
mute() {
|
|
_ensure_nag_dir
|
|
: > "${_MUTED_FILE}"
|
|
printf "Sound muted.\\n"
|
|
}
|
|
|
|
# unmute ######################################################################
|
|
|
|
describe "unmute" <<HEREDOC
|
|
Usage:
|
|
${_ME} unmute
|
|
|
|
Description:
|
|
Unmute alarm sounds.
|
|
HEREDOC
|
|
unmute() {
|
|
if [[ -f "${_MUTED_FILE}" ]]
|
|
then
|
|
rm -f "${_MUTED_FILE}"
|
|
printf "Sound unmuted.\\n"
|
|
else
|
|
printf "Sound is not muted.\\n"
|
|
fi
|
|
}
|
|
|
|
# _main must be called after everything has been defined.
|
|
_main
|