Tuesday, 9 November 2010

Remind+wyrd events in other timezones & other tricks

When I bought my EeePC I challenged myself to wherever possible find lightweight (console/curses if possible) and keyboard friendly alternatives to the software I had been using. What I discovered was that I quickly began to prefer that way of interacting with the computer to my previous KDE centric setup, so now almost all of my desktop and laptops have the same setup.

One application which I sought to replace was a calendar. I discovered a lightweight console calendar program called "remind" with a ncurses frontend known as "wyrd":

A basic event in file processed by remind might look something like this:

REM Nov 09 2010 AT 18:00 MSG Write a blog entry

That should be reasonably self explanatory. You can also specify some quite advanced recurring events in fairly natural ways:

REM Mon Tue Wed Thu Fri AT 9:00 MSG Go to work

REM Dec 25 MSG Christmas!

Or to specify the fourth Thursday of every month (Technically the next Thursday on or after the 22nd of any month):

REM Thursday 22 AT 19:00 DURATION 3:00 MSG Canberra Linux Users Group Meeting

There are also syntaxes for advanced reminders (+) and repetition (*) - but this isn't a full remind tutorial, read the man pages or search google (tip: add wyrd in your search to narrow the results down).

You may have noticed that I never specified a timezone in those examples. Unfortunately remind was written a long time ago on a hermit like platform that knew nothing of how time worked elsewhere in the world (DOS) and as a result doesn't have any support for events in other timezones built in. Just defining the event in local time may not be suitable depending on what both timezones do with daylight savings.

But there is another thing you should know about remind - it's not just a calendar domain specific language (though as you can see from those examples it certainly includes plenty of DSL constructs), it is in fact a calendar oriented programming language and we can use that to work around this limitation.

Seriously, let me say that one more time. My calendar is specified in a programming language. That is awesome. I can specify events to only occur once every blue moon---for real. I could shell out and have reminders only occur if my IP address indicates I'm at the office. Seriously, it could remind me to catch the bus only if I haven't already done so (note to self: make it do that, that would be cool).

Specifying a one off event in another timezone isn't in itself terribly difficult:

REM [trigger(tzconvert('2010-09-11@18:20', "US/Pacific"))] +30 DURATION 1:00 MSG Look up

The problem with this method is that there is no way to specify advanced recursion. tzconvert takes a datetime and returns a datetime. There's no way to say "every monday in that timezone" or "every fortnight commencing on x in that timezone" or "on the last Sunday of October every year in that timezone", which remind has no trouble doing for local events.

Remind's programing language capability is unfortunately somewhat limited - mixing the DSL grammar and functions together is a bit kludgey. It's easy to cast the output of a function to a string and use it in the grammar (as above), but going the other way is a little more difficult. For instance, variables are set using the SET command, but if there is any way to set a variable from a function it has escaped me. Functional programing techniques may be usable to work around this, but I get the impression that remind's author didn't exactly design it with that in mind - for one thing recursive calls are explicitly disallowed.

But, we can INCLUDE another file, which will then be executed by remind (even if it's included multiple times) and will be able to use the DSL commands and have access to any variables already defined, so we can use that mechanism to create a function that will do what we want. After a bit of playing around today I finally settled on this:

# SET these variables then INCLUDE this script:
# tz_src - the timezone the event is in
# tz_src_date - the date component of the event as would be passed to REM,
# including any repetition and reminders
# tz_src_time - the time component of the event in hh:mm form
# tz_src_trem - any time repetition, reminders, DURATION, etc. as passed into
# REM (if not desired, set to "")
# tz_msg - The message to print.
# Afterwards tz_dst_time will be set for *today's* occurrence of the event in
# localtime, or unset if no event occurs.

# Find next date in src timezone that occurs today() in localtime:
REM [tz_src_date] SCANFROM [trigger(today()-2)] UNTIL [trigger(today()+2)] SATISFY \
 coerce("DATE", tzconvert(datetime(trigdate(), tz_src_time), tz_src)) == today()
IF trigvalid()
 # We know local date is today from SATISFY, convert time to local:
 SET __dst_dt tzconvert(datetime(trigdate(), tz_src_time), tz_src)
 SET tz_dst_time coerce("TIME", __dst_dt)

 REM [trigger(today())] AT [tz_dst_time] [tz_src_trem] MSG [tz_msg]
 UNSET tz_dst_time

That searches for a date the event occurs on the other timezone that satisfies the condition that the event occurs today() in the local timezone (today() is not necessarily the actual system date, it could be a specific date being looked up or the date of a calendar entry being computed). The source date can be specified with any of the usual remind recurrence constructs, just like an ordinary event. I've noticed some parse errors using this with a one off event on days the event does not occur - I think it might be a bug in remind for non-recurring events with a SATISFY clause that returns 0, but if someone can see something I've done wrong there I'd welcome the feedback. Anyway, for one off events you can just use the more concise syntax above, I've tried a few different forms of recurring events and haven't yet seen it on any of them.

The title of this post says "and other tricks", so I should probably show you some. I have a weekly meeting who's time varies depending on daylight savings (to better accommodate people elsewhere in the world who call in), so I've come up with this trick checking if every Friday is in (local) daylight savings time to accommodate this (try doing this in iCal!):

IF isdst(trigdate())
 REM [trigger(trigdate())] +2 SKIP AT 09:30 DURATION 0:30 Some meeting
 REM [trigger(trigdate())] +2 SKIP AT 08:30 DURATION 0:30 Some meeting

Finally, for anyone in Canberra, here is a list of public holidays you can import into your remind file. These should take care of any of the floating public holidays as well, and you can use the SKIP keyword to have events automatically be cancelled if it falls on a public holiday, or the BEFORE or AFTER keywords to move it to another day. The only thing these can't predict is any meddling from the Government:

# Public Holidays
FSET next_monday(x) x + (7-wkdaynum(x-1))
FSET next_monday_inc(x) x + (7-wkdaynum(x-1))%7
FSET weekend(x) wkdaynum(x) == 0 || wkdaynum(x) == 6

OMIT Jan 1 SPECIAL COLOR 255 255 255 New Year's Day
REM Jan 1 SCANFROM [trigger(today()-7)] SATISFY weekend(trigdate())
OMIT [trigger(next_monday_inc(trigdate()))] SPECIAL COLOR 255 255 255 New Year's Day Holiday
OMIT Jan 26 SPECIAL COLOR 255 255 255 Australia Day
REM Jan 26 SCANFROM [trigger(today()-7)] SATISFY weekend(trigdate())
OMIT [trigger(next_monday_inc(trigdate()))] SPECIAL COLOR 255 255 255 Australia Day Holiday
REM Mon Mar 8 SCANFROM [trigger(today()-7)] SATISFY 1
OMIT [trigger(trigdate())] SPECIAL COLOR 255 255 255 Canberra Day
OMIT [TRIGGER(easter-2)] SPECIAL COLOR 255 255 255 Good Friday
REM [TRIGGER(easter-1)] SPECIAL COLOR 255 255 255 Easter Saturday
REM [TRIGGER(easter)] SPECIAL COLOR 255 255 255 Easter Sunday
OMIT [TRIGGER(easter+1)] SPECIAL COLOR 255 255 255 Easter Monday
OMIT Apr 25 SPECIAL COLOR 255 255 255 Anzac Day
REM Apr 25 SCANFROM [trigger(today()-7)] SATISFY weekend(trigdate())
OMIT [trigger(next_monday_inc(trigdate()))] SPECIAL COLOR 255 255 255 Anzac Day Holiday
REM Mon Jun 8 SCANFROM [trigger(today()-7)] SATISFY 1
OMIT [trigger(trigdate())] SPECIAL COLOR 255 255 255 Queen's Birthday
REM Mon Oct SCANFROM [trigger(today()-7)] SATISFY 1
OMIT [trigger(trigdate())] SPECIAL COLOR 255 255 255 Labour Day
OMIT 25 Dec SPECIAL COLOR 255 255 255 Christmas
OMIT 26 Dec SPECIAL COLOR 255 255 255 Boxing Day
REM 25 Dec SCANFROM [trigger(today()-7)] SATISFY weekend(trigdate())
IF trigvalid()
 OMIT [trigger(next_monday_inc(trigdate()) )] SPECIAL COLOR 255 255 255 Christmas Holiday
 OMIT [trigger(next_monday_inc(trigdate())+1)] SPECIAL COLOR 255 255 255 Boxing Day Holiday
REM 26 Dec SCANFROM [trigger(today()-7)] SATISFY wkdaynum(trigdate()) == 6
OMIT [trigger(next_monday_inc(trigdate()))] SPECIAL COLOR 255 255 255 Boxing Day Holiday

No comments: