commit 197c5b433f855e7a6536d459aae74dcc0c45a3ab Author: Darren 'Tadgy' Austin Date: Mon Dec 21 22:13:22 2020 +0000 Initial commit. diff --git a/tasksched b/tasksched new file mode 100755 index 0000000..b90f57b --- /dev/null +++ b/tasksched @@ -0,0 +1,782 @@ +#!/bin/bash +# Bash task scheduler v0.0.1. +# Copyright (C) 2020 Darren 'Tadgy' Austin . +# Licensed under the terms of the GNU General Public Licence version 3. +# +# This program comes with ABSOLUTELY NO WARRANTY. For details and a full copy of +# the license terms, see: . This is free +# software - you can modify and redistribute it under the terms of the GPL v3. + + +check_day() { + # $1 = The job ID. + # $2 = The 'crontab' style schedule entry to match. + + # Variables. + # shellcheck disable=SC2155 + local DATESECS="$(date --date="$(printf "%(%Y-%m-%d)T %s" "$DATESECS" "0:0:0")" +%s)" # Round to the top of the day. + # shellcheck disable=SC2155 + local TODAY="$(printf "%(%d)T" "$DATESECS")" + local ITEM LHS RHS TEMP + + debug " - Processing 'day' expressions" + while read -r -d , ITEM; do + debug " - Found expression: $ITEM" + case "${ITEM,,}" in + 01|02|03|04|05|06|07|08|09|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30|31) + # Matched a single day by number. + (( 10#$TODAY == 10#$ITEM )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + '*') + # Matched a single *. + debug " - Match: $DATE with $ITEM" + return 0 + ;; + *-*) + # Matched a range of days by name or number. + LHS="${ITEM%%-*}" + RHS="${ITEM##*-}" + debug " - *-* expression: LHS=$LHS RHS=$RHS" + + # Check the LHS and RHS for validity. + for TEMP in LHS RHS; do + if [[ "${!TEMP}" =~ ^($(printf "%s|" {01..09} {1..30}; printf "%s" "31"))$ ]]; then + local $TEMP="$(( 10#${!TEMP} ))" + else + die "invalid $TEMP in expression: $ITEM" + fi + done + + # The RHS can't come before the LHS. + (( LHS > RHS )) && die "invalid expression - RHS cannot preceed LHS: $ITEM" + + # Test expression. + (( RHS <= 10#$TODAY && 10#$(printf "%(%d)T" "${STATES["$1"]}") <= RHS )) || (( LHS <= 10#$TODAY && 10#$TODAY <= RHS )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + */*) + # Matched a step value of days. + LHS="${ITEM%%/*}" + RHS="${ITEM##*/}" + debug " - */* expression: LHS=$LHS RHS=$RHS" + + # Must be numbers. + [[ ! "$LHS" =~ ^([[:digit:]]+|\*)$ ]] && die "invalid LHS in expression: $ITEM" + [[ ! "$RHS" =~ ^[[:digit:]]+$ ]] && die "invalid LHS in expression: $ITEM" + + # The RHS must be >0. + (( RHS <= 0 )) && die "invalid RHS in expression: $ITEM" + + if [[ "$LHS" == "*" ]]; then + # Matched * as the LHS. + (( DATESECS == $($DADD -f %s "$(printf "%(%Y-%m-%d)T %s" "${STATES["$1"]}" "0:0:0")" "+$(( RHS - 1 ))mo") )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + elif [[ "${LHS}" =~ ^($(printf "%s|" {01..09} {1..30}; printf "%s" "31"))$ ]]; then + # Matched a day by number as the LHS. + (( DATESECS == $($DROUND -f %s "$($DADD -f "%Y-%m-%d %H:%M:%S" "$(printf "%(%Y-%m-%d)T %s" "${STATES["$1"]}" "0:0:0")" "+$(( RHS - 1 ))mo")" "$LHS") )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + else + die "invalid LHS in expression: $ITEM" + fi + ;; + *) + die "invalid expression: ${ITEM:-(empty item)}" + ;; + esac + done <<<"${2/%,}," + + # $DATE is not a match for the items spec. + return 1 +} + +check_dayofweek() { + # $1 = The job ID. + # $2 = The 'crontab' style schedule entry to match. + + # Variables + # shellcheck disable=SC2155 + local DATESECS="$(date --date="$(printf "%(%Y-%m-%d)T %s" "$DATESECS" "0:0:0")" +%s)" # Round to the top of the day. + # shellcheck disable=SC2155 + local TODAY="$(printf "%(%u)T" "$DATESECS")" + local ITEM LHS RHS DAY TEMP + + debug " - Processing 'dayofweek' expressions" + while read -r -d , ITEM; do + debug " - found expression: $ITEM" + case "${ITEM,,}" in + mon|tue|wed|thu|fri|sat|sun) + # Matched a single day by name. + (( TODAY == ${DAYS["${ITEM,,}"]} )) && { + debug " - Match: $DATE ($(printf "%(%a)T" "$DATESECS") #$(printf "%(%u)T" "$DATESECS")) with $ITEM" + return 0 + } + ;; + 01|02|03|04|05|06|07|1|2|3|4|5|6|7) + # Matched a single day by number. + (( TODAY == 10#$ITEM )) && { + debug " - Match: $DATE ($(printf "%(%a)T" "$DATESECS") #$(printf "%(%u)T" "$DATESECS")) with $ITEM" + return 0 + } + ;; + '*') + # Matched a single *. + debug " - Match: $DATE ($(printf "%(%a)T" "$DATESECS") #$(printf "%(%u)T" "$DATESECS")) with $ITEM" + return 0 + ;; + *-*) + # Matched a range of days by name or number. + LHS="${ITEM%%-*}" + RHS="${ITEM##*-}" + debug " - *-* expression: LHS=$LHS RHS=$RHS" + + # Validate the LHS and RHS, and convert day names to numbers. + for TEMP in LHS RHS; do + if [[ "${!TEMP,,}" =~ ^($(IFS='|'; printf "%s" "${!DAYS[*]}" "${DAYS[*]:+|${DAYS[*]}}"; printf "|0%s" "${DAYS[@]}"))$ ]]; then + if [[ ! "${!TEMP}" =~ ^[[:digit:]]+$ ]]; then + local $TEMP="${DAYS[${!TEMP,,}]}" + fi + local $TEMP="$(( 10#${!TEMP} ))" + else + die "invalid $TEMP in expression: $ITEM" + fi + done + + # The RHS can't come before the LHS. + (( LHS > RHS )) && die "invalid expression - RHS cannot preceed LHS: $ITEM" + + # Test expression. + (( LHS <= $(printf "%(%u)T" "$DATESECS") && $(printf "%(%u)T" "$DATESECS") <= RHS )) && { + debug " - Match: $DATE ($(printf "%(%a)T" "$DATESECS") #$(printf "%(%u)T" "$DATESECS")) with $ITEM" + return 0 + } + ;; + */*) + # Matched a step value of days. + LHS="${ITEM%%/*}" + RHS="${ITEM##*/}" + debug " - */* expression: LHS=$LHS RHS=$RHS" + + # The RHS must be a number >0. + (( RHS <= 0 )) && die "invalid RHS in expression: $ITEM" + + if [[ "$LHS" == "*" ]]; then + # Matched * as the LHS. + + # Test expression. + for (( TEMP=$(date --date="$(printf "%(%Y-%m-%d)T %s" "${STATES["$1"]}" "0:0:0")" +%s); TEMP <= DATESECS; TEMP+=(86400 * RHS) )); do + (( DATESECS == TEMP )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + done + elif [[ "${LHS,,}" =~ ^(mon|tue|wed|thu|fri|sat|sun|01|02|03|04|05|06|07|1|2|3|4|5|6|7)$ ]]; then + # Matched a day by name or number as the LHS. + + # We need to work with the name of the day, so convert day numbers. + if [[ "$LHS" =~ ^[[:digit:]]+$ ]]; then + while read -r -d ' ' DAY; do + (( ${DAYS[$DAY]} == 10#$LHS )) && break + done <<<"${!DAYS[*]} " + else + DAY="$LHS" + fi + + # Test expression. + (( DATESECS == $($DROUND -f %s "$($DADD "$(printf "%(%Y-%m-%d)T %s" "${STATES["$1"]}" "0:0:0")" "+$(( RHS - 1 ))w")" "$DAY") )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + else + die "invalid LHS in expression: $ITEM" + fi + ;; + *) + die "invalid expression: ${ITEM:-(empty item)}" + ;; + esac + done <<<"${2/%,}," + + # $DATE not a match for the items spec. + return 1 +} + +check_hour() { + # $1 = The job ID. + # $2 = The 'crontab' style schedule entry to match. + + # Variables + # shellcheck disable=SC2155 + local DATESECS="$(date --date="$(printf "%(%Y-%m-%d %H)T%s" "$DATESECS" ":0:0")" +%s)" # Round to the top of the hour. + local ITEM LHS RHS TEMP + + debug " - Processing 'hour' expressions" + while read -r -d , ITEM; do + debug " - Found expression: $ITEM" + case "$ITEM" in + 00|01|02|03|04|05|06|07|08|09|0|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23) + # Matched an hour by number. + (( 10#$ITEM == $(printf "%(%H)T" "$DATESECS") )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + '*') + # Matched a single *. + debug " - Match: $DATE with $ITEM" + return 0 + ;; + *-*) + # Matched a range of hours by name or number. + LHS="${ITEM%%-*}" + RHS="${ITEM##*-}" + debug " - *-* expression: LHS=$LHS RHS=$RHS" + + # Check the LHS and RHS for validity. + for TEMP in LHS RHS; do + if [[ "${!TEMP}" =~ ^($(printf "%s|" {0..22}; printf "%s" "23"))$ ]]; then + local $TEMP="$(( 10#${!TEMP} ))" + else + die "invalid $TEMP in expression: $ITEM" + fi + done + + # The LHS can't come before the RHS. + (( LHS > RHS )) && die "invalid expression - RHS cannot preceed LHS: $ITEM" + + # Test expression. + (( LHS <= $(printf "%(%H)T" "$DATESECS") && $(printf "%(%H)T" "$DATESECS") <= RHS )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + */*) + # Matched a step value of hours. + LHS="${ITEM%%/*}" + RHS="${ITEM##*/}" + debug " - */* expression: LHS=$LHS RHS=$RHS" + + # The RHS must be >0. + (( RHS <= 0 )) && die "invalid RHS in expression: $ITEM" + + if [[ "$LHS" == "*" ]]; then + # Matched * as the LHS. + + # Test expression. + for (( TEMP=$(date --date="$(printf "%(%Y-%m-%d %H)T%s" "${STATES["$1"]}" ":0:0")" +%s); TEMP <= DATESECS; TEMP+=(3600 * RHS) )); do + (( DATESECS == TEMP )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + done + elif [[ "${LHS}" =~ ^($(printf "%s|" {00..09} {0..22}; printf "%s" "23"))$ ]]; then + # Matched an hour as the LHS. + + # Test expression. + for (( TEMP=$($DROUND -f %s "$($DADD -f "%Y-%m-%d %H:00:00" "$(printf "%(%Y-%m-%d %H)T%s" "${STATES["$1"]}" ":0:0")" "+${RHS}d")" "+${LHS}h"); TEMP <= DATESECS; TEMP+=(3600 * RHS) )); do + (( DATESECS == TEMP )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + done + else + die "invalid LHS in expression: $ITEM" + fi + ;; + *) + die "invalid expression: ${ITEM:-(empty item)}" + ;; + esac + done <<<"${2/%,}," + + # $DATE is not a match for the items spec. + return 1 +} + +check_minute() { + # $1 = The job ID. + # $2 = The 'crontab' style schedule entry to match. + + # Variables. + # shellcheck disable=SC2155 + local DATESECS="$(date --date="$(printf "%(%Y-%m-%d %H:%M)T%s" "$DATESECS" ":0")" +%s)" # Round to the top of the minute. + local ITEM LHS RHS TEMP + + debug " - Processing 'minute' expressions" + while read -r -d , ITEM; do + debug " - found expression: $ITEM" + case "${ITEM,,}" in + 01|02|03|04|05|06|07|08|09|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34|35|36|37|38|39|40|41|42|43|44|45|46|47|48|49|50|51|52|53|54|55|56|57|58|59|60) + # Matched a single minute by number. + (( 10#$ITEM == $(printf "%(%M)T" "$DATESECS") )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + '*') + # Matched a single *. + debug " - Match: $DATE with $ITEM" + return 0 + ;; + *-*) + # Matched a range of minutes by name or number. + LHS="${ITEM%%-*}" + RHS="${ITEM##*-}" + debug " - *-* expression: LHS=$LHS RHS=$RHS" + + # Check the LHS and RHS for validity. + for TEMP in LHS RHS; do + if [[ "${!TEMP}" =~ ^($(printf "%s|" {00..09} {0..58}; printf "%s" "59"))$ ]]; then + local $TEMP="$(( 10#${!TEMP} ))" + else + die "invalid $TEMP in expression: $ITEM" + fi + done + + # The LHS can't come before the RHS. + (( LHS > RHS )) && die "invalid expression - RHS cannot preceed LHS: $ITEM" + + # Test expression. + (( LHS <= $(printf "%(%M)T" "$DATESECS") && $(printf "%(%M)T" "$DATESECS") <= RHS )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + */*) + # Matched a step value of minutes. + LHS="${ITEM%%/*}" + RHS="${ITEM##*/}" + debug " - */* expression: LHS=$LHS RHS=$RHS" + + # The RHS must be >0. + (( RHS <= 0 )) && die "invalid RHS in expression: $ITEM" + + if [[ "$LHS" == "*" ]]; then + # Matched * as the LHS. + + # Test expression. + for (( TEMP=$(date --date="$(printf "%(%Y-%m-%d %H:%M)T%s" "${STATES["$1"]}" ":0")" +%s); TEMP <= DATESECS; TEMP+=(60 * RHS) )); do + (( DATESECS == TEMP )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + done + elif [[ "${LHS}" =~ ^($(printf "%s|" {00..09} {0..58}; printf "%s" "59"))$ ]]; then + # Matched an hour as the LHS. + + # Test expression. + for (( TEMP=$($DROUND -f %s "$($DADD -f "%Y-%m-%d %H:00:00" "$(printf "%(%Y-%m-%d %H)T%s" "${STATES["$1"]}" ":0:0")" "+${RHS}h")" "+${LHS}m"); TEMP <= DATESECS; TEMP+=(60 * RHS) )); do + (( DATESECS == TEMP )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + done + else + die "invalid LHS in expression: $ITEM" + fi + ;; + *) + die "invalid expression: ${ITEM:-(empty item)}" + ;; + esac + done <<<"${2/%,}," + + # $DATE is not a match for the items spec. + return 1 +} + +check_month() { + # $1 = The job ID. + # $2 = The 'crontab' style schedule entry to match. + + # Variables. + # shellcheck disable=SC2155 + local DATESECS="$(date --date="$(printf "%(%Y-%m)T%s" "$DATESECS" "-1 0:0:0")" +%s)" # Round to the beginning of the month. + local ITEM LHS RHS MONTH TEMP + + debug " - begin processing of 'month' expressions" + while read -r -d , ITEM; do + debug " - found expression: $ITEM" + case "${ITEM,,}" in + jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|01|02|03|04|05|06|07|08|09|1|2|3|4|5|6|7|8|9|10|11|12) + # Matched a single month by name or number. + if [[ "${ITEM,,}" =~ ^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)$ ]]; then + TEMP="${MONTHS[${ITEM,,}]}" + else + TEMP="$(( 10#$ITEM ))" + fi + + # Test expression. + (( TEMP == $(printf "%(%m)T" "$DATESECS") )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + ;; + '*') + # Matched a single *. + debug " - Match: $DATE with $ITEM" + return 0 + ;; + *-*) + # Matched a range of months by name or number. + LHS="${ITEM%%-*}" + RHS="${ITEM##*-}" + debug " - *-* expression: LHS=$LHS RHS=$RHS" + + # Validate the LHS and RHS, and convert month names to numbers. + for TEMP in LHS RHS; do + if [[ "${!TEMP,,}" =~ ^($(IFS='|'; printf "%s" "${!MONTHS[*]}" "${MONTHS[*]:+|${MONTHS[*]}}"; printf "|0%s" "${MONTHS[@]}"))$ ]]; then + if [[ ! "${!TEMP}" =~ ^[[:digit:]]+$ ]]; then + local $TEMP="${MONTHS[${!TEMP,,}]}" + fi + local $TEMP="$(( 10#${!TEMP} ))" + else + die "invalid $TEMP in expression: $ITEM" + fi + done + + # The RHS can't come before the LHS. + (( LHS > RHS )) && die "invalid expression - RHS cannot preceed LHS: $ITEM" + + # Test expression. + (( LHS <= $(printf "%(%m)T" "$DATESECS") && $(printf "%(%m)T" "$DATESECS") <= RHS )) && { + debug " - Match: $DATE ($(printf "%(%b)T" "$DATESECS") #$(printf "%(%m)T" "$DATESECS")) with $ITEM" + return 0 + } + ;; + */*) + # Matched a step iteration of months. + LHS="${ITEM%%/*}" + RHS="${ITEM##*/}" + debug " - */* expression: LHS=$LHS RHS=$RHS" + + # The RHS must be >0. + (( RHS <= 0 )) && die "invalid RHS in expression: $ITEM" + + if [[ "$LHS" == "*" ]]; then + # Matched * as the LHS. + + # Test expression. + while read -r TEMP; do + (( TEMP == DATESECS )) && { + debug " Match: $DATE with $ITEM" + return 0 + } + done < <($DSEQ -f %s "$(printf "%(%Y-%m)T%s" "${STATES["$1"]}" "-1 0:0:0")" "${RHS}mo" "$(printf "%(%Y-%m)T%s" "$DATESECS" "-1 0:0:0")") + elif [[ "${LHS,,}" =~ ^($(printf "%s|" jan feb mar apr may jun jul aug sep oct nov dec {01..09} {1..11}; printf "%s" "12"))$ ]]; then + # Marched a month name or number as the LHS. + + # The RHS must be >0. + (( RHS <= 0 )) && die "invalid RHS in expression: $ITEM" + + # We need to work with the name of the month, so convert month numbers. + if [[ "$LHS" =~ ^[[:digit:]]+$ ]]; then + while read -r -d ' ' MONTH; do + (( ${MONTHS[$MONTH]} == 10#$LHS )) && break + done <<<"${!MONTHS[*]} " + else + MONTH="$LHS" + fi + + # Test expression. + while read -r TEMP; do + (( TEMP == DATESECS )) && { + debug " - Match: $DATE with $ITEM" + return 0 + } + done < <($DSEQ -f %s "$($DADD -f "%Y-%m-1 0:0:0" "$($DROUND -f "%Y-%m-1 0:0:0" "$(printf "%(%Y-%m)T%s" "${STATES["$1"]}" "-1 0:0:0")" "$MONTH")" "$(( RHS -1 ))y")" "${RHS}y" "$(printf "%(%Y-%m)T%s" "$DATESECS" "-1 0:0:0")") + else + die "invalid LHS in expression: $ITEM" + fi + ;; + *) + die "invalid expression: ${ITEM:-(empty item)}" + ;; + esac + done <<<"${2/%,}," + + # $DATE is not a match for the items spec. + return 1 +} + +debug() { + (( DEBUG == 1 )) && error "debug: $*" +} + +die() { + error "fatal: $*" + exit 1 +} + +display_help() { + #........1.........2.........3.........4.........5.........6.........7.........8 + cat <<-EOF + Usage: ${0##*/} [options] '' + Parse a crontab style and return true or false as to whether + the current date/time means the should be run. + + Options: + -d , --date + Instead of using the current date/time for the comparison against the + , use this date and time specification instead. + This must be given as a single argument, enclosed in single (') or double + (") quotes if it contains spaces, and must be a valid date accepted by + 'date --date'. Note: by default UTC is expected, but a specifically given + date/time + TZ will be converted to UTC. + -q, --quiet + Normally, when the scheduler spec> indicates the job should be run, the + name of the is output to stdout. With this option, ${0##*/} + operates quietly and will only output errors to stderr. The return code + issued to indicate whether the job would be run is unaffected. + -s , --statefile + Use the given as the state file for maintaining a record of + the last execution time of the job IDs. + The default statefile is $FIXME. + Using this option allows non-root users to use the scheduler's job + tracking functionallity. If the file provided does not exist, it will + be created if possible, or an error will be emitted if the + (or default system wide file) cannot be written. + -t, --test + Parse the schedule and return the result, but do not update the + statefile. This allows for testing of schedules and dates. + -z, --debug + Enable debugging output, which will be spamey. + -- + Cease option processing and begin argument parsing. + Option processing ceases with the first non-option argument or --. + + Arguments (all of which are mandatory): + + This is the 'crontab' style specification of the schedule on which the + given should be executed. This schedule is compared against the + current (or provided by option --date/-d) date/time. + The schedule is given as a single argument enclosed in single (') or + double (") quotes, with each component separated by spaces. Be mindful of + shell globbing while using a '*' on the command line. + See documentation for a full rundown of the accepted. + + FIXME + may not contain the character ':'. This is only restriction on + job identifications. + EOF +} + +display_version() { + #........1.........2.........3.........4.........5.........6.........7.........8 + cat <<-EOF + Bash task scheduler v0.0.1. + Copyright (C) 2020 Darren 'Tadgy' Austin . + Licensed under the terms of the GNU General Public Licence version 3. + + This program comes with ABSOLUTELY NO WARRANTY. For details and a full copy of + the license terms, see: . This is free + software - you can modify and redistribute it under the terms of the GPL v3. + EOF +} + +error() { + printf "%s: %s\\n" "${0##*/}" "$*" >&2 +} + +parse_options() { + # Parse the command line options. + while [[ -n "$1" ]]; do + case "$1" in + -d|-date|--date) + [[ -z "$2" ]] && die "date cannot be an empty value" + DATE="$(date --date="$2" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)" || die "invalid date specification: $2" + shift + ;; + -q|-quiet|--quiet) + (( DEBUG == 1 )) && die "cannot use -q/--quiet with -z/--debug" + QUIET=1 + ;; + -s|-statefile|--statefile) + [[ -z "$2" ]] && die "state file cannot be an empty value" + STATEFILE="$2" + shift + ;; + -t|-test|--test) + TEST=1 + ;; + -h|-\?|-help|--help) + display_help + exit 0 + ;; + -v|-version|--version) + display_version + exit 0 + ;; + -z|-debug|--debug) + (( QUIET == 1 )) && die "cannot use -z/--debug with -q/--quiet" + DEBUG=1 + ;; + --) + shift + break + ;; + --*|-*) + die "invalid option: $1" + return 1 + ;; + *) + break + ;; + esac + shift + done + + # Make sure we have a schedule spec and job ID after all the options are removed. + if (( $# < 2 )) || (( $# >= 3 )) || [[ -z "$1" ]] || [[ -z "$2" ]]; then + error "Usage: ${0##*/} [options] '' " + error "Try: ${0##*/} --help" + return 1 + else + (( $(printf "%s" "$1" | awk '{print NF}') != 5 )) && die "wrong number of components in scheduler spec: $1" + SCHEDSPEC="$1" + debug "Found valid schedule spec: $SCHEDSPEC" + + [[ "$2" =~ : ]] && die "invalid character (:) in job ID: $1" + JOBID="$2" + debug "Found job ID: $JOBID" + fi +} + +readstate() { + local LINE + + while read -r LINE; do + STATES["${LINE%%:*}"]="${LINE##*:}" + debug "Read statefile entry: ${LINE%%:*} = ${LINE##*:}" + done <"$STATEFILE" +} + +writestate() { + local FD TEMP + + # Lock the statefile for writing. + debug "Opening and locking statefile" + exec {FD}>"$STATEFILE" + flock -w 1 -E 10 $FD + TEMP="$?" + if (( TEMP == 10 )); then + die "failed to obtain lock on statefile" + elif (( TEMP != 0 )); then + die "flock error on statefile" + fi + + # Output the data in the STATES array to the statefile. + for JOBID in "${!STATES[@]}"; do + printf "%s:%s\\n" "$JOBID" "${STATES["$JOBID"]}" >&$FD + debug "Wrote statefile entry: $JOBID = ${STATES["$JOBID"]}" + done + + # Close statefile and release lock. + debug "Releasing statefile lock and closing" + exec {FD}>&- +} + + +tasksched() { + # Bash v4.0+ is required. + [[ -z "${BASH_VERSINFO[0]}" ]] || ((BASH_VERSINFO[0] < 4)) && die "minimum of bash v4 required" + + # Make life easier and stick to a single TZ. + export TZ=UTC + + # Set defaults. + # shellcheck disable=SC2155 + local DATE="$(date "+%Y-%m-%d %H:%M:%S")" # The date/time to compare the scheduler spec to. Use current date/time by default. + local DEBUG=0 # Whether to emit debugging messages. + local STATEFILE="$HOME/.config/${0##*/}.state" # The default state file. + local QUIET=0 # Whether to print the job ID if the schedule spec matches. 0 = print, 1 = don't print. + local TEST=0 # Whether this execution is a test run. 0 = no, 1 = yes. + + # Variables. + local DAY DAYOFWEEK HOUR JOBID MINUTE MONTH SCHEDSPEC TEMP + local -A STATES + local -A DAYS=([mon]=1 [tue]=2 [wed]=3 [thu]=4 [fri]=5 [sat]=6 [sun]=7) + local -A MONTHS=([jan]=1 [feb]=2 [mar]=3 [apr]=4 [may]=5 [jun]=6 [jul]=7 [aug]=8 [sep]=9 [oct]=10 [nov]=11 [dec]=12) + + # Parse options. + parse_options "$@" + + # Convert the supplied/default date to seconds from the epoch. + # shellcheck disable=SC2155 + local DATESECS="$(date --date="$DATE" +%s)" + + # Figure out the naming of the dateutils binaries. + # Fucking Debian/Ubuntu being different, of course. + for TEMP in dadd dround dseq; do + if hash "$TEMP" 2>/dev/null; then + local ${TEMP^^}="$(hash -t "$TEMP")" + elif hash "dateutils.$TEMP" 2>/dev/null; then + local ${TEMP^^}="$(hash -t "dateutils.$TEMP")" + else + die "cannot locate dateutils' $TEMP binary" + fi + done + + # Check statefile. + if [[ -e "$STATEFILE" ]]; then + [[ ! -r "$STATEFILE" ]] && die "cannot read statefile: $STATEFILE" + [[ ! -w "$STATEFILE" ]] && die "cannot write statefile: $STATEFILE" + else + mkdir -p "${STATEFILE%/*}" + touch "$STATEFILE" 2>/dev/null || die "cannot create statefile: $STATEFILE" + fi + + # Confirm what date we're using. + debug "Using date: $DATE" + + # Read the states from the statefile. + readstate + + # Initialise the STATES array with the JOBID if necessary. + [[ -z "${STATES["$JOBID"]}" ]] && STATES["$JOBID"]="$DATESECS" + + # Print the last time the job went off. + debug "Timestamp for job ID '$JOBID': $(printf "%(%Y-%m-%d %H:%M:%S)T\\n" "${STATES["$JOBID"]}")" + + # Extract components from schedule spec. + MINUTE="${SCHEDSPEC%% *}"; SCHEDSPEC="${SCHEDSPEC#* }" + HOUR="${SCHEDSPEC%% *}"; SCHEDSPEC="${SCHEDSPEC#* }" + DAY="${SCHEDSPEC%% *}"; SCHEDSPEC="${SCHEDSPEC#* }" + MONTH="${SCHEDSPEC%% *}"; SCHEDSPEC="${SCHEDSPEC#* }" + DAYOFWEEK="${SCHEDSPEC%% *}" + + # Verify that the spec was parsed correctly. + [[ -z "$MINUTE" ]] || [[ -z "$HOUR" ]] || [[ -z "$DAY" ]] || [[ -z "$MONTH" ]] || [[ -z "$DAYOFWEEK" ]] && die "failed to parse schedule spec" + + # Check the specification against $DATE. + debug "Testing expressions:" + check_month "$JOBID" "$MONTH" && \ + check_dayofweek "$JOBID" "$DAYOFWEEK" && \ + check_day "$JOBID" "$DAY" && \ + check_hour "$JOBID" "$HOUR" && \ + check_minute "$JOBID" "$MINUTE" + ERRNO="$?" + + (( ERRNO == 0 )) && { + debug "Matched specification to $DATE" + + debug "Updating status date" + STATES["$JOBID"]="$DATESECS" + + (( TEST == 0 )) && writestate + (( QUIET == 0 )) && printf "%s\n" "$JOBID" + } + + return $ERRNO +} + +# If using the above functions in a script, adjust the call to 'tasksched' below to use your chosen options. +tasksched "$@"