4

I wrote the following bash script as a budgeting tool to more accurately calculate (and moderate) when I last bought a pack of cigarettes.

In addition to -h for printing the output, it takes one other option: -b with (what is intended to be) an optional parameter to set the offset in hours. So -b 5 logs a new pack of cigs bought 5 hours ago, and subsequent runs of cigs.sh indicate how many weeks, days or hours it's been since then. If the elapsed time since the last time I bought a pack is less than $threshold, the output is printed in red; if the elapsed time is more than $threshold, the output is green, indicating it's now "okay" for me to buy a new pack.

I've managed to get all this working fine except for one thing: it's currently not possible to specify -b without an offset (i.e. log the purchase as being made right now instead of some hours into the past). As far as I've been able to tell this seems to be a limitation of getopts: without the colon in b:h, $OPTARG fails to populate causing the logic in the b) case option to fail; with the colon, it prevents -b from being used without a parameter, throwing the following error:

/usr/local/bin/scripts/cigs.sh: option requires an argument -- b
Unknown parameter passed: -b

Here is the getopts portion of my script:

while getopts "b:h" OPTION; do
    case $OPTION in
    h)
        echo "$usage"
        exit
        ;;
    b)
        offset=$OPTARG
        if [[ -n $offset && ! $offset =~ ^[0-9]+$ ]]; then 
        echo "HOURS parameter passed to -b must be an integer."
        exit
        fi
        echo -e "Bought a new $item...\nResetting timer to 0 days and $offset hours."
        offset=$((offset*60*60))
        last_bought="$(date -u +%s)"
        new_lb=$((last_bought-offset))
        echo $new_lb > $lb_file
        exit
        ;;
    *)
        echo "Unknown parameter passed: $1"
        exit 1
        ;;
    esac
done
Hashim Aziz
  • 13,835

1 Answers1

2

Very interesting issue here. Based on what I understand about how getopts works, this use of a single colon (:) means that the option value must be set:

getopts "b:h"

And if you wanted it to be optional, you would need to set two colons (::) and also have nothing follow that like this:

getopts "hb::"

But that hb:: doesn’t work.

I played around with your script and this is the only solution I could get to work. It changes your getopts "b:h" to be getopts "bh" so both option parameters are optional. But the magic is in adding more code to the b) case so the whole while chunk now looks like this:

while getopts "bh" OPTION; do
    case $OPTION in
    h)
        echo "$usage"
        exit
        ;;
    b)
        eval nextopt=\${$OPTIND}
        if [[ -n $nextopt && $nextopt != -* ]] ; then
        OPTIND=$((OPTIND + 1))
        offset=$nextopt
        else
        offset=0
        fi
        if [[ -n $offset && ! $offset =~ ^[0-9]+$ ]]; then
        echo "HOURS parameter passed to -b must be an integer."
        exit
        fi
        echo -e "Bought a new $item...\nResetting timer to 0 days and $offset hours."
        offset=$((offset*60*60))
        last_bought="$(date -u +%s)"
        new_lb=$((last_bought-offset))
        echo $new_lb > $lb_file
        exit
        ;;
    *)
        echo "Unknown parameter passed: $1"
        exit 1
        ;;
    esac
done

The magic comes from this chunk over here that is based on this other Stack Overflow answer to a question about the same issue:

# Check next positional parameter
eval nextopt=\${$OPTIND}
# existing or starting with dash?
if [[ -n $nextopt && $nextopt != -* ]] ; then
OPTIND=$((OPTIND + 1))
offset=$nextopt

Clearly not as clean as what would be expect if the getopts "hb::" worked, but it doesn’t so this kludge works.

If anyone else more experienced with Bash can explain why — or come up with a cleaner solution — they should post it. For now, this works so it works!

Hashim Aziz
  • 13,835
Giacomo1968
  • 58,727