0

Like in sleep: sleep 1h 30m 50s. But ideally with even more flexibility like in time calculator with support time corrections with minus sign.

Examples:

  • time2seconds (t2s) 1h30m50s # basic case - result should be: 5450 seconds
  • t2s 1h30m-30s # with correction by minus sign - result should be: 5370 seconds
  • t2s 1h-5s # result should be 3595 seconds
  • t2s 15m*3 # it might also be convenient to use multiplication sign - result should be: 2700 seconds

Below is my own script with draft solution but it needs to be rewritten to have more performance respect (as described by markp-fuso now it creates huge amount of subshells)


Implementation below in time2seconds function. Usages examples:

time2seconds 1h30m50s debug  # 5450
t2s 1h30m-30s d  # 5370
t2s 1h-5s d  # 3595
t2s 15m*3 d  # 2700
log(){
    msg_content="$1"
    print_to_console=${2:-"print_to_console"}

    msg="`date +"%Y-%m-%dT%H:%M:%S"` - $msg_content"

    if [ $print_to_console = "print_to_console" ]; then
        echo -e "$msg"
    fi
}

FUNCTION_RESULT=  # global var, used for functions to return values

alias t2s="time2seconds"

# Calculates for entered time amount of seconds
# Supported formats:
# 1d1h1m1s
# hh:mm:ss (for "backwards compatibility", for those who got used to this format)
# Any parts can be omitted. Examples of valid input:
# 1h, 1h30m, 1d5s, 30m, 100h, 200m, 300s, 59 (seconds), 1m30 (1m30s), 1h1h1h (3h)
# 102:30:59, 2:3:59, 3:59, 100:200:300, 500 (seconds)
# For 1d1h1m1s format supports correction of input by means of calculations like:
# 1h-1m (result is 59m), 1d-1h30m (result is 22h30m), 15m*3 (result is 45m)
# To debug time to seconds conversion algorithm (print detailed output to console):
# time2seconds 1d-1h30m+5 debug  # test run
# t2s 1d10h30s-1h1m d  # alternative test run with aliases
function time2seconds() {

    start_time=$(date +%s%3N)

    time="${1}"
    out="${2:-"do_not_print_to_console"}"

    if [ "$out" = "debug" ] || [ "$out" = "d" ] || [ "$out" = "print" ]; then
        out="print_to_console"
    fi

    case "$time" in
        *d*|*h*|*m*|*s*)
            log "handling several cases for d, h, m, s tokens" $out
            time=$(echo "$time" | sed -E 's/([[:digit:]]+)([^[:alnum:]])/\1s\2/g')
            log "time1, added missing s tokens: $time" $out

            time=$(echo "$time" | sed -E 's/([[:digit:]]+)$/\1s/')
            log "time2, added missing s token to the very end: $time" $out

            time=$(echo "$time" | sed -E 's/([[:alnum:]]+)/\(\1\)/g')
            log "time3, added round braces (): $time" $out

            time=$(echo "$time" | sed -E 's/([[:digit:]]+)d/+\1*24*3600;/g')
            log "time4, days to seconds: $time" $out

            time=$(echo "$time" | sed -E 's/([[:digit:]]+)h/+\1*3600;/g')
            log "time5, hours to seconds: $time" $out

            time=$(echo "$time" | sed -E 's/([[:digit:]]+)m/+\1*60;/g')
            log "time6, mins to seconds: $time" $out

            time=$(echo "$time" | sed -E 's/([[:digit:]]+)s/+\1;/g')
            log "time7, seconds to seconds: $time" $out

            time=$(echo "$time" | sed -E 's/;//g')
            log "time8, semicolon ; delimeter removed: $time" $out

            time=$(echo "$time" | sed -E 's/\(\+/(/g')
            log "time9, leading plus signs removed: $time" $out

            calculated_seconds=$(echo "$time" | bc)
            ;;
        *)
            log "handling case with colon (:)" $out
            calculated_seconds=$(echo "$time" | sed -E 's/(.*):(.+):(.+)/\1*3600+\2*60+\3/;s/(.+):(.+)/\1*60+\2/' | bc)
            ;;
        *)
    esac
    
    log "calculated_seconds by bc: $calculated_seconds" $out

    end_time=$(date +%s%3N)
    duration_ms=$((end_time - start_time))

    log "time2seconds execution time in ms: $duration_ms" $out
    
    FUNCTION_RESULT="$calculated_seconds"  # global var is used to return result from function
}

Related questions:

2
  • 1
    We encourage questioners to show what they have tried so far to solve the problem themselves.
    – Cyrus
    Commented May 18 at 23:55
  • @Cyrus I moved my draft solution from answer section to the question Commented Jun 4 at 15:38

2 Answers 2

1

In zsh instead of bash, you could do something like:

function t2s time2seconds {
  set -o{localoptions,extendedglob,histsubstpattern}
  local s=1 m=60 h=60*m d=24*h w=7*d spec=${(j[ ])argv:l}
  spec=$spec:gs/[[:space:],]##/' '
  spec=$spec:gs/(#b)(<->):(<->):(<->)/$match[1]h$match[2]m$match[3]s

  if [[ $spec = *[^0-9.smhdw' '/*+-]* ]]; then
    print -ru2 Invalid duration specification
    return 1
  fi
  spec=$spec:gs/(#m)[smhdw]/*$MATCH
  spec=$spec:fs/(#b)([smhdw])' '#([0-9.smhdw])/$match[1]+$match[2]
  spec=$spec:fs/(#b)([0-9.])' '([0-9.])/$match[1]+$match[2]
  spec=$spec:fs/(#b)([-+])([-+])/$match[1]' '$match[2]

  printf '%d\n' spec
}
alias t2s='noglob t2s' time2seconds='noglob time2seconds'

And then:

$ t2s 1h30m50s
5450
$ t2s 1h30m-30s
5370
$ t2s 15m*3
2700
$ t2s 1w - 6d -  1h -5*12m * 11 -11*60m - 4*15m
0
$ t2s 1.5w - 10d - 11.75h - 30m/2
0
$ t2s 01:06:40, -3000s
1000

Walking through that code:

function t2s time2second {

That's the Korn-style function f { script; } function definition syntax (as opposed to the Bourne-style f() command) here extended to define more than one function at a time (a zsh extension). I usually prefer the Bourne-style syntax (even if Korn's predates it), but here using the Korn style one helps in the face of aliases in case that code was to be interpreted twice in the same shell.

  set -o{localoptions,extendedglob,histsubstpattern}

Setting options using the Korn-style syntax (see also setopt ... and options[...]=on which are zsh-specific) and csh-style brace expansion. localoptions is so that the subsequent option modifications are kept local to the function (in the Korn shell, that was done by default; the Almquist shell has local - for that). extendedglob to be able to use extended glob operators such as ## or (#b)/(#m), histsubstpattern so we can use a pattern in the :s/foo/bar/ csh-style modifier.

  local s=1 m=60 h=60*m d=24*h w=7*d spec=${(j[ ])argv:l}

local (from the Almquist shell) so those variables are locale to the function. typeset from the Korn shell would also work.

$argv:l: csh-style modifier to convert the positional parameters to lower case upon expansion (csh-style $argv similar to Bourne/Korn $* but more legible), ${(j[ ])array} joins the elements of the array with spaces. Bourne's "$*" would also work but only if $IFS is not modified from its default as the joining is done with the first character of $IFS like in ksh instead of space like in the Bourne shell.

  spec=$spec:gs/[[:space:],]##/' '

globally substitute all occurrences of 1 or more (##) whitespace characters or commas with one space.

  spec=$spec:gs/(#b)(<->):(<->):(<->)/$match[1]h$match[2]m$match[3]s

Replace <number>:<number>:<number> with <number>h<number>m<number>s to handle the 01:06:40 specifications like in your attempt. (#b) activates back-references so the portions matched by individual (...) are available in the $match array in the replacement.

  if [[ $spec = *[^0-9.smhdw' '/*+-]* ]]; then
    print -ru2 Invalid duration specification
    return 1
  fi

Bail out if the specifications contains any character other than those we're ready to handle. It's important to do some form of sanitisation as otherwise you would introduce a command injection vulnerability. Here, it's not a strict sanitisation as the user could still give invalid code like /*+- but those should be harmless and caught by the shell arithmetic parser invoked by printf below.

  spec=$spec:gs/(#m)[smhdw]/*$MATCH

We prepend a * to all of those single letter units so 12h5m becomes 12*h5*m. This time using (#m) which causes the whole match to be made available as $MATCH in the replacement.

  spec=$spec:fs/(#b)([smhdw])' '#([0-9.smhdw])/$match[1]+$match[2]

The main point of using $var:s/pattern/replacement/ instead of the now more popular Korn-style ${var//pattern/replacement} is for that extra f feature, which causes the substitution to be repeats as long as it changes something.

Here we're inserting a + between units and following number (or other units; allowing spaces) so that our 12*h5*m from above becomes 12*h+5*m and hms becomes h+m+s. In that latter case using :gs would not work as that works like sed's s/.../.../g and only scans the input string once making the next substitution only on the part left after the previous substitution, so after hm is replaced with h+m, the remaining part is s, not ms.

  spec=$spec:fs/(#b)([0-9.])' '([0-9.])/$match[1]+$match[2]

Same to change 1 2*m to 1+2*m (one second + 2 minutes).

  spec=$spec:fs/(#b)([-+])([-+])/$match[1]' '$match[2]

Break down 1*h----2*m into 1*h- - - -2*m (same for ++ and collaterally for -+/+- even if that's not needed) to avoid -- and ++ from being treated as decrement/increment operators.

  printf '%d\n' spec

spec here interpreted as a shell arithmetic expression and converted to integer by printf's %d.

}
alias t2s='noglob t2s' time2seconds='noglob time2seconds'

Creating those aliases so that: t2s 3 * 15m be replaced with noglob t2s 3 * 15m during code reading so that upon evaluation globbing be disabled on the command line so * be not expanded to all the files in the current working directory as it would otherwise.

0
1

Consider researching performance improvements, especially in terms of eliminating all the expensive subshell invocations.


Eliminate a single subshell invocation by replacing the pipes with here-strings, eg:

#### replace this:

$(echo "$time" | sed ... )          # spawns 2 subshells

#### with this:

$(sed ... <<< "$time")              # spawns 1 subshell

Going a step further you should be able to use parameter substitution to eliminate most (if not all) of these single subshell invocations, eg:

time='3d1h30m-30s'
hr="${time%h*}"              # hr = 3d1
hr="${hr#*[^[0-9]}"          # hr = 1

You should be able to use similar constructs to parse in from the left or right; yeah, a bit more typing but from a runtime perspective this is still going to be a lot faster than spawning subshells.


Concerning the subshell calls to date ...

With bash 4.3+ you can use printf to grab the current date without having to a) call date or b) spawn a subshell.

Consider the following equivalents:

$ msg="$(date +"%Y-%m-%dT%H:%M:%S")"        # spawns 1 subshell
$ echo "$msg"
2024-05-18T13:06:35

$ printf -v msg "%(%Y-%m-%dT%H:%M:%S)T"     # no subshell required
$ echo "$msg"
2024-05-18T13:06:35

Getting milliseconds takes a bit more work; see this Q&A for some ideas on obtaining milliseconds.


Once you get a (performance-related) rewrite completed consider using the bash shell keyword time to see the differences in run time: currennt process w/ 37 (!!) subshell invocations vs new process with 0 subshell invocations, eg:

time t2s     1h30m-30s

time t2s.new 1h30m-30s

I think you'll be pleasantly surprised by the significant performance improvement.


Plus a couple issues with the log function:

  • if log is called with out=do_not_print_to_console then no final result is is not printed/displayed to the user
  • consider moving msg="'date ...'" inside the if so you don't spawn the subshell unless you need msg to be populated
  • consider replacing the backticks with parens like you do for the date calls in t2s
  • obviously the subshell invocation )to call date) goes away if you use the printf -v msg fix, but even then populating msg pnly when needed is still a good idea
3
  • That is great review! Thank you. consider replacing the backticks with parens like you do for the date calls in t2s - is there some difference between using backticks and $()? I thought that they do the same thing. Commented May 19 at 8:52
  • 1
    backticks is the 'old' method, $() is the 'new' method; backticks are hard to nest; (imo) $() stands out more while backticks (on small screens or for people with poor vision) can be mixed up with single quotes; there are probably a couple more benefits but having said that ... yeah, they do the same thing
    – markp-fuso
    Commented May 19 at 12:22
  • 2
    printf %T (mis-copied in an incompatible way from ksh93) is from 4.3, not 3.1. printf -v is from 3.1 though. Commented May 19 at 13:41

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .