A Simple On-Screen-Display Notification Timer

This is a re-write of a Ruby implementation into Tcl. The original idea is from: http://richardmavis.info/a-complete-program

#!/usr/bin/env tclsh
# Barry Arthur, 2019-2-13
# Original: https://github.com/rmavis/timer/blob/master/timer (http://richardmavis.info/a-complete-program)

# Returns the necessary key:varname pairs that [dict update] expects,
# and which are painful to write out by hand.
proc using_keys {keys} {
  concat {*}[lmap x $keys {list $x $x}]
}

# Returns the singular or plural form of unit.
proc inflect {quantity unit} {
  return ${unit}[expr {$quantity == 1 ? "" : "s"}]
}

namespace eval timer {
  variable default_config {
    delay   5
    title   Timer
    time    {}
    message {}
    command {}
  }
  variable time_units {
    d {desc day    mult {60 * 60 * 24}}
    h {desc hour   mult {60 * 60}}
    m {desc minute mult  60}
    s {desc second mult   1}
  }
}

proc timer::help_message {} {
  variable default_config
  # following line allows here-doc style of text block.
  subst [regsub -all -lineanchor "^\\s*> ?" {
    > timer: sleep a while, then send a notification via `notify-send`.
    >
    > To specify the delay time, provide one or more arguments in the form:
    >
    >   <DURATION>\[<UNITS>\]
    >
    > UNITS:
    >   d = days
    >   h = hours
    >   m = minutes
    >   s = seconds (default)
    >
    > If you provide multiple time-delay arguments, their values will accrue.
    > So you can delay 90 seconds either with `90s` or `1m 30s`.
    >
    > You can also provide a message and a title to use for the notification.
    > The first argument given that does not look like a time-delay argument
    > will be used for the message. The second will be used for the title.
    > Both the message and title are optional. If neither are given, "[dict get $default_config title]"
    > will be used as the title, and the message will state the delay duration.
    >
    > The notification will be sent via `notify-send`. If you'd like to run a
    > custom command instead, you can specify that with the `-c` flag, e.g.,
    > `-c "path/to/command"`. Information about the timer will be passed to
    > the command via standard input.
    >
    > Examples:
    >   timer 5m Tea
    >   timer 1h 10m Laundry
    >   timer 45 "Fresh is best" Pasta
    >   timer 30d "Up 30 days" -c "~/bin/post_uptime_notice"
  } {}]
}

proc timer::default_command {title {message {}}} {
  return "notify-send -u critical \"$title\" \"$message\""
}

proc timer::custom_command {conf} {
  return "echo \"$conf\" | [dict get $conf command] &"
}

proc timer::time_format {delay} {
  variable time_units
  # days:hours:minutes:seconds
  set  durations [split [clock format $delay -timezone UTC -format "%d:%T"] :]
  # days starts at 1 so reset to zero-based
  lset durations 0 [expr {[lindex $durations 0] - 1}]
  # remove leading-zero on numbers for better presentation
  # and to prevent octal parsing.
  set  durations [lmap x $durations {regsub -all {\m0} $x {}}]
  set  inflected [lmap d $durations u [dict keys $time_units] {
    if {$d eq ""} continue
    list $d [inflect $d [dict get $time_units $u desc]]
  }]
  if {[llength $inflected] > 1} {
    concat [join [lrange $inflected 0 end-1] {, }] and [lindex $inflected end]
  } else {
    join $inflected
  }
}

proc timer::normalize_conf {conf} {
  variable default_config
  dict update conf {*}[using_keys [dict keys $default_config]] {
    if {$title eq ""} {
      set title [dict get $default_config title]
    }
    if {$delay == 0} {
      set delay [dict get $default_config delay]
    }
    set time [time_format $delay]
    if {$message eq ""} {
      set message $time
    }
    if {$command eq ""} {
      set command [default_command $title $message]
    } else {
      set command [custom_command $conf]
    }
  }
  return $conf
}

proc timer::from_stdin {} {
  variable default_config
  set conf [concat {*}[split [read stdin] \n]]
  # remove unset elements so they don't shadow default in merge
  set conf [dict remove $conf [dict filter $conf value {}]]
  set conf [normalize_conf [dict merge $default_config $conf]]
  return $conf
}

proc timer::parse_cmdline_args {cmdline_args} {
  variable    default_config
  variable    time_units
  set delay   0
  set title   {}
  set message {}
  set command {}

  set time_units_charclass [join [dict keys $time_units] {}]
  set time_regex [subst -nocommands {^([0-9]+)([$time_units_charclass])?\$}]

  for {set i 0} {$i < [llength $cmdline_args]} {incr i} {
    set arg [lindex $cmdline_args $i]
    if {[string tolower $arg] == "-h" || [string tolower $arg] == "--help"} {
      puts [help_message]
      exit
    } elseif {$arg eq "-"} {
      set conf [from_stdin]
      # update local variables from conf dictionary
      dict update conf {*}[using_keys [dict keys $default_config]] {}
    } elseif {[string tolower $arg] == "-c" || [string tolower $arg] == "--command"} {
      set command [lindex $cmdline_args [incr i]]
    } elseif {[regexp $time_regex $arg -> duration units]} {
      if {$units eq "" || [string tolower $units] == "s"} {
        incr delay [expr {$duration * [dict get $time_units s mult]}]
      } else {
        incr delay [expr {$duration * [dict get $time_units [string tolower $units] mult]}]
      }
    } elseif {$message eq ""} {
      set message $arg
    } else {
      set title $arg
    }
  }

  return [normalize_conf [dict create \
    delay   $delay    \
    title   $title    \
    message $message  \
    command $command  \
  ]]
}

proc timer::init {cmdline_args} {
  set conf [parse_cmdline_args $cmdline_args]
  set pid  [exec sh -c "(sleep [dict get $conf delay] ; [dict get $conf command])" &]
  puts "\[$pid\] Timer set for [dict get $conf time]."
}

timer::init $argv

Discussion


aplsimple - 2019-02-14 05:37:16

>   <DURATION>\[<UNITS>\]

no?

Bah! You're right. I failed to test that after updating the synopsis. Thanks.