diff --git a/nag b/nag index c11d701..b0f7a68 100755 --- a/nag +++ b/nag @@ -45,6 +45,7 @@ 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/bell.oga}" _MUTE_FILE="${NAG_DIR}/mute" +_SNOOZED_FILE="${NAG_DIR}/snoozed" # The default subcommand if no args are passed. NAG_DEFAULT="${NAG_DEFAULT:-list}" @@ -211,23 +212,32 @@ _piped_input() { } # Usage: -# _confirm_action +# _confirm_action [] # # Prompts the user for confirmation before performing a bulk action on -# tagged alarms. Skipped when -f (force/_YES) is set. In non-interactive -# mode, prints a message asking the caller to pass -f. +# alarms. When is provided, the prompt includes the tag name. +# Skipped when -f (force/_YES) is set. In non-interactive mode, prints +# a message asking the caller to pass -f. # # Exit / Error Status: # 0 (success, true) If the action is confirmed. # 1 (error, false) If the action is denied or non-interactive without -f. _confirm_action() { - local _verb="${1}" _count="${2}" _tag="${3}" + local _verb="${1}" _count="${2}" _tag="${3:-}" (( _YES )) && return 0 + local _desc + if [[ -n "${_tag}" ]] + then + _desc="${_count} alarm(s) tagged [${_tag}]" + else + _desc="${_count} alarm(s)" + fi + if _interactive_input then - printf "%s %s alarm(s) tagged [%s]? [y/N] " "${_verb}" "${_count}" "${_tag}" + printf "%s %s? [y/N] " "${_verb}" "${_desc}" local _reply read -r _reply case "${_reply}" in @@ -235,7 +245,7 @@ _confirm_action() { *) return 1 ;; esac else - printf "%s %s alarm(s) tagged [%s]? Pass -f to confirm.\n" "${_verb}" "${_count}" "${_tag}" + printf "%s %s? Pass -f to confirm.\\n" "${_verb}" "${_desc}" return 1 fi } @@ -325,6 +335,8 @@ _SUBCOMMAND="" _SUBCOMMAND_ARGUMENTS=() _USE_DEBUG=0 _YES=0 +_RAW_TIME=0 +_EPOCH_TIME=0 while ((${#})) do @@ -346,6 +358,12 @@ do -f) _YES=1 ;; + --iso) + _RAW_TIME=1 + ;; + --epoch) + _EPOCH_TIME=1 + ;; *) # The first non-option argument is assumed to be the subcommand name. # All subsequent arguments are added to $_SUBCOMMAND_ARGUMENTS. @@ -644,6 +662,176 @@ _is_muted() { return 1 } +# Usage: +# _is_snoozed +# +# Description: +# Check whether an alarm ID is currently snoozed. If the entry has an +# expiry timestamp, it is only considered snoozed when the expiry is +# still in the future. +# +# Exit / Error Status: +# 0 (success, true) If the alarm is snoozed. +# 1 (error, false) If the alarm is not snoozed. +_is_snoozed() { + local _id="${1}" + [[ -f "${_SNOOZED_FILE}" ]] || return 1 + + local _now _entry + _now="$(date +%s)" + + while IFS= read -r _entry || [[ -n "${_entry}" ]] + do + [[ -n "${_entry}" ]] || continue + local _entry_key="${_entry%%$'\t'*}" + + [[ "${_entry_key}" == "${_id}" ]] || continue + + # Check expiry if present. + if [[ "${_entry}" == *$'\t'* ]] + then + local _expiry="${_entry#*$'\t'}" + (( _expiry > _now )) && return 0 + else + return 0 + fi + done < "${_SNOOZED_FILE}" + + return 1 +} + +# Usage: +# _sweep_expired_snoozes +# +# Description: +# Remove stale snooze entries: expired (past expiry timestamp) and +# orphaned (alarm ID no longer exists in _ALARMS). Expects _ALARMS +# to be populated via _read_alarms before calling. +_sweep_expired_snoozes() { + [[ -f "${_SNOOZED_FILE}" ]] || return 0 + + local _now + _now="$(date +%s)" + local -a _keep=() + local _entry + + while IFS= read -r _entry || [[ -n "${_entry}" ]] + do + [[ -n "${_entry}" ]] || continue + + local _entry_id="${_entry%%$'\t'*}" + + # Drop expired entries. + if [[ "${_entry}" == *$'\t'* ]] + then + local _expiry="${_entry#*$'\t'}" + (( _expiry <= _now )) && continue + fi + + # Drop orphaned entries (alarm no longer exists). + local _exists=0 _line + for _line in "${_ALARMS[@]:-}" + do + [[ -n "${_line}" ]] || continue + [[ "${_line%%$'\t'*}" == "${_entry_id}" ]] && _exists=1 && break + done + (( _exists )) || continue + + _keep+=("${_entry}") + done < "${_SNOOZED_FILE}" + + if (( ${#_keep[@]} == 0 )) + then + rm -f "${_SNOOZED_FILE}" + else + printf "%s\n" "${_keep[@]}" > "${_SNOOZED_FILE}.tmp" + mv -f "${_SNOOZED_FILE}.tmp" "${_SNOOZED_FILE}" + fi +} + +# Usage: +# _get_snooze_display +# +# Description: +# Set REPLY to a display string for the given alarm ID's snooze status. +# Returns " (snoozed)" for indefinite snoozes, " (snoozed until )" +# for timed snoozes, or "" if the alarm is not snoozed. +_get_snooze_display() { + local _id="${1}" + REPLY="" + [[ -f "${_SNOOZED_FILE}" ]] || return 0 + + local _now _entry + _now="$(date +%s)" + + while IFS= read -r _entry || [[ -n "${_entry}" ]] + do + [[ -n "${_entry}" ]] || continue + local _entry_key="${_entry%%$'\t'*}" + + [[ "${_entry_key}" == "${_id}" ]] || continue + + if [[ "${_entry}" == *$'\t'* ]] + then + local _expiry="${_entry#*$'\t'}" + if (( _expiry > _now )) + then + _format_time "${_expiry}" + REPLY=" (snoozed until ${REPLY})" + fi + else + REPLY=" (snoozed)" + fi + return 0 + done < "${_SNOOZED_FILE}" +} + +# Usage: +# _remove_snoozed_entry +# +# Description: +# Remove the entry whose key matches from the snoozed file. +# Returns 1 if no matching entry was found or the file does not exist. +_remove_snoozed_entry() { + local _key="${1}" + [[ -f "${_SNOOZED_FILE}" ]] || return 1 + + local -a _keep=() + local _removed=0 _entry + while IFS= read -r _entry || [[ -n "${_entry}" ]] + do + [[ -n "${_entry}" ]] || continue + local _entry_key="${_entry%%$'\t'*}" + if [[ "${_entry_key}" == "${_key}" ]] + then + _removed=1 + else + _keep+=("${_entry}") + fi + done < "${_SNOOZED_FILE}" + + (( _removed )) || return 1 + + if (( ${#_keep[@]} == 0 )) + then + rm -f "${_SNOOZED_FILE}" + else + printf "%s\n" "${_keep[@]}" > "${_SNOOZED_FILE}.tmp" + mv -f "${_SNOOZED_FILE}.tmp" "${_SNOOZED_FILE}" + fi + return 0 +} + +# Usage: +# _cleanup_alarm_metadata +# +# Description: +# Remove all metadata for an alarm ID (snoozed file entries). +# Called when an alarm is permanently removed. +_cleanup_alarm_metadata() { + _remove_snoozed_entry "${1}" 2>/dev/null || true +} + # Usage: # _tag_list # @@ -697,7 +885,11 @@ _tag_list() { _tag_display=" [${_tags//,/, }]" - printf "[%s]%s %s%s — %s\\n" "${_id}" "${_tag_display}" "${_human_time}" "${_rule_display}" "${_message}" + local _snooze_display + _get_snooze_display "${_id}" + _snooze_display="${REPLY}" + + printf "[%s]%s %s%s%s — %s\\n" "${_id}" "${_tag_display}" "${_human_time}" "${_rule_display}" "${_snooze_display}" "${_message}" done if (( ! _matched )) @@ -718,6 +910,18 @@ _tag_list() { _format_time() { local _timestamp="${1}" + if (( _EPOCH_TIME )) + then + REPLY="${_timestamp}" + return + fi + + if (( _RAW_TIME )) + then + REPLY="$(date -d "@${_timestamp}" "+%Y-%m-%d %H:%M:%S")" + return + fi + # Single date call to get all needed components. local _date_parts _date_parts="$(date -d "@${_timestamp}" "+%-I|%M|%p|%Y-%m-%d|%A|%a %b %-d")" @@ -768,12 +972,21 @@ _format_time() { _date_prefix="Tomorrow" elif (( _days_away <= 6 )) then - _date_prefix="This ${_day_name}" + _date_prefix="${_day_name}" elif (( _days_away <= 13 )) then _date_prefix="Next ${_day_name}" else - _date_prefix="${_short_date}" + local _alarm_year + _alarm_year="$(date -d "${_alarm_date}" +%Y)" + local _this_year + _this_year="$(date +%Y)" + if [[ "${_alarm_year}" != "${_this_year}" ]] + then + _date_prefix="${_short_date} ${_alarm_year}" + else + _date_prefix="${_short_date}" + fi fi REPLY="${_date_prefix}, ${_time_fmt}" @@ -1020,6 +1233,87 @@ _first_occurrence() { fi } +# Usage: +# _unskip_timestamp +# +# Description: +# Compute the next natural occurrence of a repeating alarm from now. +# Handles each rule type appropriately: day-of-week rules use today's +# date at alarm's time, month preserves day-of-month, year preserves +# month and day. For comma-separated rules, returns the earliest. +_unskip_timestamp() { + local _timestamp="${1}" _rules="${2}" + local _now + _now="$(date +%s)" + + local _time_of_day _alarm_day _alarm_month + _time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)" + _alarm_day="$(date -d "@${_timestamp}" +%-d)" + _alarm_month="$(date -d "@${_timestamp}" +%-m)" + + local _earliest="" + local IFS="," + local _rule + for _rule in ${_rules} + do + local _candidate="" + case "${_rule}" in + hour) + _candidate="$((_now - (_now % 3600) + $(date -d "@${_timestamp}" +%s) % 3600))" + if (( _candidate <= _now )); then + _candidate=$((_candidate + 3600)) + fi + ;; + day) + _candidate="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)" + if (( _candidate <= _now )); then + _candidate="$(date -d "$(date +%Y-%m-%d) + 1 day ${_time_of_day}" +%s)" + fi + ;; + month) + local _year_now _month_now + _year_now="$(date +%Y)" + _month_now="$(date +%-m)" + _candidate="$(date -d "$(printf "%04d-%02d-%02d %s" "${_year_now}" "${_month_now}" "${_alarm_day}" "${_time_of_day}")" +%s 2>/dev/null)" || _candidate=0 + if (( _candidate <= _now )); then + if (( _candidate == 0 )); then + _candidate="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)" + fi + _candidate="$(_next_month "${_candidate}" "${_time_of_day}")" + fi + ;; + year) + local _year_now + _year_now="$(date +%Y)" + _candidate="$(date -d "$(printf "%04d-%02d-%02d %s" "${_year_now}" "${_alarm_month}" "${_alarm_day}" "${_time_of_day}")" +%s 2>/dev/null)" || _candidate=0 + if (( _candidate <= _now )); then + if (( _candidate == 0 )); then + _candidate="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)" + fi + _candidate="$(_next_year "${_candidate}" "${_time_of_day}")" + fi + ;; + *) + # Day-of-week rules (weekday, weekend, mon-sun, week). + local _today_at_alarm_time + _today_at_alarm_time="$(date -d "$(date +%Y-%m-%d) ${_time_of_day}" +%s)" + _candidate="$(_first_occurrence "${_rule}" "${_today_at_alarm_time}")" + if (( _candidate <= _now )); then + _candidate="$(_next_occurrence "${_rule}" "${_candidate}")" + fi + ;; + esac + + if [[ -n "${_candidate}" ]] && (( _candidate > 0 )); then + if [[ -z "${_earliest}" ]] || (( _candidate < _earliest )); then + _earliest="${_candidate}" + fi + fi + done + + printf "%s" "${_earliest}" +} + # Usage: # _timestamp_matches_rule # @@ -1222,11 +1516,14 @@ Usage: ${_ME} list all alarms ${_ME}