From d198b5c2a4b409d9f5316bfa152ebf6d912f022c Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 2 Apr 2026 15:57:58 +0100 Subject: [PATCH] feat: tag subcommand for adding tags and listing by tag --- nag | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/tag.bats | 64 +++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 test/tag.bats diff --git a/nag b/nag index 6c7d453..c1a8dc3 100755 --- a/nag +++ b/nag @@ -530,6 +530,89 @@ _get_alarm_field() { fi } +# Usage: +# _alarm_has_tag +# +# Description: +# Check whether a comma-separated tags field contains a specific tag. +# +# Exit / Error Status: +# 0 (success, true) If the tag is present. +# 1 (error, false) If the tag is absent or the field is empty. +_alarm_has_tag() { + local _tags_field="${1}" _tag="${2}" + [[ -n "${_tags_field}" ]] || return 1 + local IFS="," + local _t + for _t in ${_tags_field} + do + [[ "${_t}" == "${_tag}" ]] && return 0 + done + return 1 +} + +# Usage: +# _tag_list +# +# Description: +# List all alarms that carry the given tag, sorted by timestamp. +_tag_list() { + local _filter_tag="${1}" + + if [[ ! -f "${_ALARMS_FILE}" ]] || [[ ! -s "${_ALARMS_FILE}" ]] + then + printf "No alarms tagged [%s].\\n" "${_filter_tag}" + return 0 + fi + + _read_alarms + + local -a _sorted + IFS=$'\n' _sorted=($(printf "%s\n" "${_ALARMS[@]}" | sort -t$'\t' -k3 -n)) + IFS=$'\n\t' + + local _today _today_epoch + _today="$(date +%Y-%m-%d)" + _today_epoch="$(date -d "${_today}" +%s)" + + local _matched=0 + local _line + for _line in "${_sorted[@]}" + do + [[ -n "${_line}" ]] || continue + local _id _tags _timestamp _rule _message + + _id="${_line%%$'\t'*}"; local _rest="${_line#*$'\t'}" + _tags="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}" + _timestamp="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}" + _rule="${_rest%%$'\t'*}" + _message="${_rest#*$'\t'}" + + _alarm_has_tag "${_tags}" "${_filter_tag}" || continue + _matched=1 + + local _human_time _rule_display _tag_display + _format_time "${_timestamp}" "${_today_epoch}" + _human_time="${REPLY}" + + if [[ -n "${_rule}" ]] + then + _rule_display=" (${_rule//,/, })" + else + _rule_display="" + fi + + _tag_display=" [${_tags//,/, }]" + + printf "[%s]%s %s%s — %s\\n" "${_id}" "${_tag_display}" "${_human_time}" "${_rule_display}" "${_message}" + done + + if (( ! _matched )) + then + printf "No alarms tagged [%s].\\n" "${_filter_tag}" + fi +} + # Usage: # _format_time [today_epoch] # @@ -1504,5 +1587,96 @@ edit() { "${EDITOR:-${VISUAL:-vi}}" "${_ALARMS_FILE}" } +# tag ######################################################################### + +describe "tag" < add tags to an alarm + ${_ME} tag list alarms with a tag + +Description: + Add tags to an alarm by ID, or list all alarms matching a tag. + Tags must not be pure integers. +HEREDOC +tag() { + local _first="${1:-}" + [[ -n "${_first}" ]] || _exit_1 printf "Usage: %s tag or %s tag \\n" "${_ME}" "${_ME}" + + if [[ ! "${_first}" =~ ^[0-9]+$ ]] + then + _tag_list "${_first}" + return + fi + + local _target_id="${_first}" + shift + [[ -n "${1:-}" ]] || _exit_1 printf "Usage: %s tag \\n" "${_ME}" + + local _tag + for _tag in "$@" + do + if [[ "${_tag}" =~ ^[0-9]+$ ]] + then + _exit_1 printf "Tag cannot be a number: %s\\n" "${_tag}" + fi + done + + _acquire_lock + _read_alarms + + local -a _new_alarms=() + local _found=0 + local _result_tags="" + local _line + + for _line in "${_ALARMS[@]:-}" + do + [[ -n "${_line}" ]] || continue + local _id _existing_tags _timestamp _rule _message + _id="${_line%%$'\t'*}"; local _rest="${_line#*$'\t'}" + if [[ "${_id}" == "${_target_id}" ]] + then + _found=1 + _existing_tags="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}" + _timestamp="${_rest%%$'\t'*}"; _rest="${_rest#*$'\t'}" + _rule="${_rest%%$'\t'*}" + _message="${_rest#*$'\t'}" + + local -a _tag_arr=() + if [[ -n "${_existing_tags}" ]] + then + IFS=',' read -r -a _tag_arr <<< "${_existing_tags}" + fi + + for _tag in "$@" + do + local _already=0 _t + for _t in "${_tag_arr[@]:-}" + do + [[ "${_t}" == "${_tag}" ]] && _already=1 && break + done + (( _already )) || _tag_arr+=("${_tag}") + done + + _result_tags="$(_join "," "${_tag_arr[@]}")" + _new_alarms+=("$(printf "%s\t%s\t%s\t%s\t%s" "${_id}" "${_result_tags}" "${_timestamp}" "${_rule}" "${_message}")") + else + _new_alarms+=("${_line}") + fi + done + + if (( ! _found )) + then + _release_lock + _exit_1 printf "No alarm with ID %s.\\n" "${_target_id}" + fi + + _ALARMS=("${_new_alarms[@]}") + _write_alarms + _release_lock + + printf "Tagged [%s] %s.\\n" "${_target_id}" "${_result_tags//,/, }" +} + # _main must be called after everything has been defined. _main diff --git a/test/tag.bats b/test/tag.bats new file mode 100644 index 0000000..9a5c1f4 --- /dev/null +++ b/test/tag.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats + +load test_helper + +@test "tag adds a single tag to an alarm" { + run_nag at "tomorrow 3pm" "test alarm" + run_nag tag 1 work + [ "${status}" -eq 0 ] + local _tags + _tags="$(cut -f2 "${NAG_DIR}/alarms")" + [ "${_tags}" = "work" ] +} + +@test "tag adds multiple tags to an alarm" { + run_nag at "tomorrow 3pm" "test alarm" + run_nag tag 1 work meetings + [ "${status}" -eq 0 ] + local _tags + _tags="$(cut -f2 "${NAG_DIR}/alarms")" + [ "${_tags}" = "work,meetings" ] +} + +@test "tag merges with existing tags without duplicates" { + run_nag at "tomorrow 3pm" "test alarm" + run_nag tag 1 work + run_nag tag 1 meetings work + [ "${status}" -eq 0 ] + local _tags + _tags="$(cut -f2 "${NAG_DIR}/alarms")" + [ "${_tags}" = "work,meetings" ] +} + +@test "tag rejects pure integer tag names" { + run_nag at "tomorrow 3pm" "test alarm" + run_nag tag 1 123 + [ "${status}" -eq 1 ] +} + +@test "tag with nonexistent ID fails" { + run_nag tag 99 work + [ "${status}" -eq 1 ] +} + +@test "tag without arguments fails" { + run_nag tag + [ "${status}" -eq 1 ] +} + +@test "tag with non-numeric arg lists matching alarms" { + run_nag at "tomorrow 3pm" "work task" + run_nag tag 1 work + run_nag at "tomorrow 4pm" "personal task" + run_nag tag work + [ "${status}" -eq 0 ] + [[ "${output}" =~ "work task" ]] + [[ ! "${output}" =~ "personal task" ]] +} + +@test "tag list shows no matches message" { + run_nag at "tomorrow 3pm" "test alarm" + run_nag tag nonexistent + [ "${status}" -eq 0 ] + [[ "${output}" =~ "No alarms tagged" ]] +}