#!/bin/bash # tsd - convert a number to a human-readable timestamp or duration # # Name comes from "timestamp or duration" -> "ts d" -> "tsd" # Pass a unix epoch timestamp (e.g. 1609477200) to get a datetime (2021-01-01T05:00:00Z) # Pass a duration (e.g. 7238) to get a human-readable form (2h 0m 38s) # Datetimes are printed in both UTC and the user's local time # Unit defaults to seconds but goes up to nanoseconds (e.g. for CF WAF logs) # Use -d,--duration to force duration mode when a long duration would be detected as a timestamp # # Usage: # tsd [options] # tsd -h # # Options: # -s, --sec, --seconds Set unit to seconds (default) # -m, --milli, --milliseconds Set unit to milliseconds # -u, --micro, --microseconds Set unit to microseconds # -n, --nano, --nanoseconds Set unit to nanoseconds # -d, --duration Override timestamp detection, treat input as a duration # -h, --help Show help message # # Examples: # Parse a timestamp from a log: # $ tsd 1609477200 # 2021-01-01T05:00:00Z # 2021-01-01 00:00:00 EST (GMT-5) # # Check how long a 1800-second access token lasts: # $ tsd 1800 # 30m # # Explain 1800 milliseconds like you're five: # $ tsd 1800 -m # or `tsd -m 1800` # 1s 800ms _tsd() ( # Microseconds (the 3 places following milliseconds) is abbreviated μs, as in the Greek letter mu # In this script we use `us` to get close enough, as ms is obviously taken by milliseconds local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="tsd" ;; esac _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } local input local unit # Set to default value later, if still unset after processing arguments local digits local duration_explicit local is_timestamp # Allow local timezone to be overridden, for testing local -r LOCALTIME="${TSD_LOCALTIME:-"/etc/localtime"}" _expand_short_opts "" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED #region parameter processing # Handle parameters while [ $# -gt 0 ]; do case "$1" in # -t|--timestamp) # TODO: Add back alongside a length verification, returning an error if it doesn't match for the unit # [ "$duration_explicit" ] && { echo "Error: Both -d and -t cannot be specified." >&2; return 1; } # is_timestamp=true # ;; -d|--duration) # [ "$is_timestamp" ] && { echo "Error: Both -t and -d cannot be specified." >&2; return 1; } duration_explicit=true ;; -s|--sec|--seconds) [ "$unit" ] && { _error "Multiple units not allowed (already set to '$unit'). Run \`$SCRIPT_NAME -h\` for usage"; return 2; } unit="s" ;; -m|--milli|--milliseconds) [ "$unit" ] && { _error "Multiple units not allowed (already set to '$unit'). Run \`$SCRIPT_NAME -h\` for usage"; return 2; } unit="ms" ;; -u|--micro|--microseconds) [ "$unit" ] && { _error "Multiple units not allowed (already set to '$unit'). Run \`$SCRIPT_NAME -h\` for usage"; return 2; } unit="us" ;; -n|--nano|--nanoseconds) [ "$unit" ] && { _error "Multiple units not allowed (already set to '$unit'). Run \`$SCRIPT_NAME -h\` for usage"; return 2; } unit="ns" ;; #region help -h|--help) echo "Usage:" printf '\t%s\n' "$SCRIPT_NAME [options] timestamp_or_duration" echo printf '\t%s\n' "Provide a duration (e.g. 7238) or unix epoch timestamp (e.g. 1609477200) to get a human-readable" printf '\t%s\n' "duration (e.g. 2h 0m 38s) or datetime (e.g. 2021-01-01T05:00:00Z), respectively." echo echo "Options:" printf '\t%s\t\t%s\n' "-s, --sec, --seconds" "Set unit to seconds (default)" printf '\t%s\t%s\n' "-m, --milli, --milliseconds" "Set unit to milliseconds" printf '\t%s\t%s\n' "-u, --micro, --microseconds" "Set unit to microseconds" printf '\t%s\t%s\n' "-n, --nano, --nanoseconds" "Set unit to nanoseconds" # printf '\t%s\t\t\t%s\n' "-t, --timestamp" "Interpret the input as a timestamp" printf '\t%s\t\t\t%s\n' "-d, --duration" "Override timestamp detection, treating input as a duration" printf '\t%s\t\t\t%s\n' "-h, --help" "Display this help message" echo echo "Examples:" printf '\t%s\n' "Parse a Unix epoch timestamp:" printf '\t%s\n' "$ $SCRIPT_NAME 1609477200" printf '\t%s\n' "> 2021-01-01T05:00:00Z" printf '\t%s\n' "> 2021-01-01 00:00:00 EST (GMT-5)" echo printf '\t%s\n' "Check how long a B2C sandbox took to start:" printf '\t%s\n' "$ sfcc-ci sandbox:start zzzu-010" printf '\t%s\n' "> Starting 5e568774-dec5-455d-a396-7d93cce00e1d was triggered. Waiting for sandbox to finish starting..." printf '\t%s\n' "> Sandbox zzzu-010 with ID 5e568774-dec5-455d-a396-7d93cce00e1d, was found with status \`started\` (135180 ms)." printf '\t%s\n' "$ $SCRIPT_NAME 135180 -m" printf '\t%s\n' "> 2m 15s 180ms" echo printf '\t%s\n' "Check the duration of a 1800-second access token:" printf '\t%s\n' "$ $SCRIPT_NAME 1800" printf '\t%s\n' "> 30m" echo printf '\t%s\n' "Explain 1800 milliseconds like you're five:" printf '\t%s\n' "$ $SCRIPT_NAME 1800 -m # or $SCRIPT_NAME -m 1800" printf '\t%s\n' "> 1s 800ms" echo echo "Exit status:" printf '\t%s\t%s\n' "0" "Success" printf '\t%s\t%s\n' "2" "Usage error (missing input, unknown option, multiple units, multiple positionals)" return 0 ;; #endregion *) if [ -z "$input" ]; then # Handle the positional argument input="$1" digits=${#input} else _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi ;; esac shift done #endregion # If no timestamp/duration provided, return an error if [ -z "$input" ]; then _error "No input provided. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi # If no unit provided, infer from length of input if [ -z "$unit" ]; then if [ "$digits" -le 10 ]; then unit="s" elif [ "$digits" -le 13 ]; then unit="ms" elif [ "$digits" -le 16 ]; then unit="us" else unit="ns" fi fi # "Unset" timestamp flag if input was explicitly specified to represent a duration if [ "$duration_explicit" ]; then is_timestamp='' else # If provided number is the right length, detect it as a timestamp if { [ "$unit" = "s" ] && [ "$digits" -eq 10 ]; } || \ { [ "$unit" = "ms" ] && [ "$digits" -eq 13 ]; } || \ { [ "$unit" = "us" ] && [ "$digits" -eq 16 ]; } || \ { [ "$unit" = "ns" ] && [ "$digits" -eq 19 ]; }; then is_timestamp=true else is_timestamp='' fi fi #region timestamp logic if [ "$is_timestamp" ]; then # macOS `date` can only handle unix timestamps in seconds, so we need to truncate the input down to 10 digits # We'll inject ms/us/ns back into the timestamp after running `date` to avoid losing precision # ISO8601 doesn't specify a max precision for the fractional seconds, so we'll still be in spec local s local ms local us local ns case "$unit" in s) # Input should already be in the form we need (10 digits) s="$input" ;; ms) # Extract milliseconds ms="${input:10:3}" s="${input:0:10}" ;; us) # Extract milliseconds and microseconds us="${input:13:3}" ms="${input:10:3}" s="${input:0:10}" ;; ns) # Extract milliseconds, microseconds, and nanoseconds ns="${input:16:3}" us="${input:13:3}" ms="${input:10:3}" s="${input:0:10}" ;; esac local times="$ms$us$ns" # Convert timestamp to datetime in UTC (ISO8601) and local time (custom format) # Use "Z" instead of "+00:00" offset local utc_date; utc_date=$(TZ=UTC date -r "$s" "+%Y-%m-%dT%H:%M:%SZ") local local_date; local_date="$(TZ="$LOCALTIME" date -r "$s" "+%Y-%m-%d %H:%M:%S %Z (%z)")" # Inject ms, us, ns back into timestamp if [ "$ms" ] || [ "$us" ] || [ "$ns" ]; then # Insert fractional seconds before the trailing Z utc_date="${utc_date%Z}.${times}Z" # Put the fractional seconds before the second space, which comes after seconds local_date="$(printf %s "$local_date" | sed "s/ /.$times /2")" fi # Edit GMT/UTC offset for legibility ("-0500" -> "GMT-5", "+1245" -> "GMT+12:45") # "2023-11-06 18:46:28.123456 EST (-0500)" -> "2023-11-06 18:46:28.123456 EST (GMT-5)" local_date="$(printf %s "$local_date" | sed " s/(-0\{0,1\}/(GMT-/; # (-0400) -> (GMT-400), (-1000) -> (GMT-1000) s/(+0\{0,1\}/(GMT+/; # (+0400) -> (GMT+400), (+1000) -> (GMT+1000) s/00)/)/; # (GMT-400) -> (GMT-4) s/30)/:30)/; # (GMT+1230) -> (GMT+12:30) s/45)/:45)/; # (GMT+1245) -> (GMT+12:45) ")" # If generating either datetime failed entirely (empty string), fall back to duration logic if [ -z "$utc_date" ] || [ -z "$local_date" ]; then is_timestamp='' else echo "$utc_date" echo "$local_date" return 0 fi fi #endregion #region duration logic # If it's not a timestamp, convert it to a human-readable duration format if [ -z "$is_timestamp" ]; then # Not using an `else` because the previous `if` block could modify this variable local duration='' local -i days=0 hours=0 minutes=0 seconds=0 milliseconds=0 microseconds=0 nanoseconds=0 # Calculate each portion # 64-bit signed integers, so max value is (2^63)-1 == 9,223,372,036,854,775,807 (19 digits) if [ "$unit" = "s" ]; then days=$((input / 86400)) hours=$((input % 86400 / 3600)) minutes=$((input % 3600 / 60)) seconds=$((input % 60)) elif [ "$unit" = "ms" ]; then days=$((input / 86400000)) hours=$((input % 86400000 / 3600000)) minutes=$((input % 3600000 / 60000)) seconds=$((input % 60000 / 1000)) milliseconds=$((input % 1000)) elif [ "$unit" = "us" ]; then days=$((input / 86400000000)) hours=$((input % 86400000000 / 3600000000)) minutes=$((input % 3600000000 / 60000000)) seconds=$((input % 60000000 / 1000000)) milliseconds=$((input % 1000000 / 1000)) microseconds=$((input % 1000)) elif [ "$unit" = "ns" ]; then days=$((input / 86400000000000)) # "Only" 11 digits - max value is 2^63-1 (9223372036854775808), 19 digits long hours=$((input % 86400000000000 / 3600000000000)) minutes=$((input % 3600000000000 / 60000000000)) seconds=$((input % 60000000000 / 1000000000)) milliseconds=$((input % 1000000000 / 1000000)) microseconds=$((input % 1000000 / 1000)) nanoseconds=$((input % 1000)) fi # Only display zeroes if they're in the middle: e.g. "2d 0h 0m 5s" for 172805 but "2d" for 172800 if [ $days -gt 0 ]; then duration="${days}d" fi if [ $hours -gt 0 ] || { [ $days -gt 0 ] && { [ $minutes -gt 0 ] || [ $seconds -gt 0 ] || [ $milliseconds -gt 0 ] || [ $microseconds -gt 0 ] || [ $nanoseconds -gt 0 ]; } }; then [ "$duration" ] && duration="$duration " duration="${duration}${hours}h" fi if [ $minutes -gt 0 ] || { { [ $days -gt 0 ] || [ $hours -gt 0 ]; } && { [ $seconds -gt 0 ] || [ $milliseconds -gt 0 ] || [ $microseconds -gt 0 ] || [ $nanoseconds -gt 0 ]; } }; then [ "$duration" ] && duration="$duration " duration="${duration}${minutes}m" fi if [ $seconds -gt 0 ] || { { [ $days -gt 0 ] || [ $hours -gt 0 ] || [ $minutes -gt 0 ]; } && { [ $milliseconds -gt 0 ] || [ $microseconds -gt 0 ] || [ $nanoseconds -gt 0 ]; } }; then [ "$duration" ] && duration="$duration " duration="${duration}${seconds}s" fi if [ $milliseconds -gt 0 ] || { { [ $days -gt 0 ] || [ $hours -gt 0 ] || [ $minutes -gt 0 ] || [ $seconds -gt 0 ]; } && { [ $microseconds -gt 0 ] || [ $nanoseconds -gt 0 ]; } }; then [ "$duration" ] && duration="$duration " duration="${duration}${milliseconds}ms" fi if [ $microseconds -gt 0 ] || { { [ $days -gt 0 ] || [ $hours -gt 0 ] || [ $minutes -gt 0 ] || [ $seconds -gt 0 ] || [ $milliseconds -gt 0 ]; } && { [ $nanoseconds -gt 0 ]; } }; then [ "$duration" ] && duration="$duration " duration="${duration}${microseconds}us" fi if [ $nanoseconds -gt 0 ]; then [ "$duration" ] && duration="$duration " duration="${duration}${nanoseconds}ns" fi echo "$duration" fi #endregion ) _tsd "$@" __tsd_rc=$? unset -f _tsd if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __tsd_rc; return $__tsd_rc" fi eval "unset __tsd_rc; exit $__tsd_rc"