I'm currently playing with XSLT to improve my XMLTV grabbing solution. Much fun playing with a functionnal language available in most web browsers.
I had the need to handle timezone with daylight savings to display date/times in my timezone, Europe/Paris
(same as most of western Europe), which has currently the following definition (I'm not interested in history for this project):
- Winter is UTC + 01:00
- Summer is UTC + 02:00
- Transition from winter to summer occurs on the last sunday of March at 01:00 UTC
- Transition from summer to winter occurs on the last sunday of October at 01:00 UTC
So I wrote the following template date-time-Paris
which is very specialized as it handles only one specific timezone, but it is very fast. I'm using only two extensions from EXSLT: date:day-in-week()
and date:add()
.
xml version="1.0" encoding="UTF-8"
<xslstylesheet version="1.0"
xmlnsxsl="http://www.w3.org/1999/XSL/Transform"
xmlnstap="test:tap"
xmlnsdate="http://exslt.org/dates-and-times"
extension-element-prefixes="date"
>
<xsltemplate name="date-time-Paris">
<xslparam name="date-time-Z"/>
<xslvariable name="len" select="string-length($date-time-Z)"/>
<xslvariable name="seps" select="translate($date-time-Z, '0123456789', '')"/>
<xslif test="$len < 11 or substring($date-time-Z, $len, 1) != 'Z' or ($seps != '--T::Z' and $seps != '--T::.Z' and $seps != '--Z' and $seps != '--TZ' and $seps != '--T:Z')">
<xslmessage terminate="yes">date-time-Paris: date '<xslvalue-of select="$date-time-Z"/>' invalide.</xslmessage>
</xslif>
<xslvariable name="month" select="number(substring($date-time-Z, 6, 2))"/>
<xslvariable name="offset">
<xslchoose>
<xslwhen test="$month < 3 or $month > 10">1</xslwhen>
<xslwhen test="$month > 3 and $month < 10">2</xslwhen>
<xslotherwise>
<xslvariable name="transition-day" select="32 - date:day-in-week(concat(substring($date-time-Z, 1, 7), '-31Z'))"/>
<xslvariable name="less" select="translate(substring($date-time-Z, 8), '-T:Z', '') < $transition-day*1000000+10000"/>
<xslvalue-of select="1+number(($less and $month = 10) or (not($less) and $month = 3))"/>
</xslotherwise>
</xslchoose>
</xslvariable>
<xslvalue-of select="concat(translate(date:add($date-time-Z, concat('PT', $offset, 'H')), 'Z', ''), '+0', $offset, ':00')"/>
</xsltemplate>
The implementation is really short, however it is quite complex as XSLT 1.0 requires to uses many tricks to workaround languages limitations (no native date type, no string order, no xor operator). Here are some tips to follow the code:
- (a xor b) is equivalent to ((a and not(b) ) and (not(a) and b)).
- number(true()) is 1, number(false()) is 0.
- March and October have the same number of days: 31.
$transition-day*10000000+10000
for october 2008 (26010000) is an number representation of 2008-10-26T01:00:00Z.
- the second argument to
date:add()
is a ISO 8601 duration. Ex: PT2H (2 hours).
In fact I present here the fourth version of the code, the first one was much longer (5x) as I was using intermediate templates for computations (last-sunday-of-month, previous-sunday). Of course the key to debug, and then optimise and refactor the implementation was to start with a good test suite. The test suite is of course written in XML and embedded in the stylesheet so it can evolve with the code.
<tapsuite name="date-time-Paris" xmlnst="test:date-time-Paris">
<tt dt="2008-09-24T21:37:52Z" r="2008-09-24T23:37:52+02:00"/>
<tt dt="2008-09-24T23:37:52Z" r="2008-09-25T01:37:52+02:00"/>
<tt dt="2008-09-24T23:37:52.325Z" r="2008-09-25T01:37:52.325+02:00"/>
<tt dt="2008-10-01T00:00:00Z" r="2008-10-01T02:00:00+02:00"/>
<tt dt="2008-10-26T00:59:59Z" r="2008-10-26T02:59:59+02:00"/>
<tt dt="2008-10-26T01:00:00Z" r="2008-10-26T02:00:00+01:00"/>
<tt dt="2008-10-31T12:00:00Z" r="2008-10-31T13:00:00+01:00"/>
<tt dt="2008-11-24T21:37:52Z" r="2008-11-24T22:37:52+01:00"/>
<tt dt="2008-12-31T23:30:00Z" r="2009-01-01T00:30:00+01:00"/>
<tt dt="2009-01-01T00:00:00Z" r="2009-01-01T01:00:00+01:00"/>
<tt dt="2009-03-01T12:00:00Z" r="2009-03-01T13:00:00+01:00"/>
<tt dt="2009-03-29T00:59:59Z" r="2009-03-29T01:59:59+01:00"/>
<tt dt="2009-03-29T01:00:00Z" r="2009-03-29T03:00:00+02:00"/>
<tt dt="2009-03-30T12:00:00Z" r="2009-03-30T14:00:00+02:00"/>
<tt dt="2009-07-01T00:00:00Z" r="2009-07-01T02:00:00+02:00"/>
</tapsuite>
A more complete testsuite can be generated from a local dump of the zoneinfo database (directly works on Ubuntu Hardy, however the zdump
tool is missing on RHEL/CentOS):
zdump -v Europe/Paris | perl -ne 'm/ (Mar|Oct) (\d{2}) (\d{2}:\d{2}:\d{2}) (20[0-5]\d) UTC = ... (?:Oct|Mar) (\d{2}) (\d{2}:\d{2}:\d{2}) / && do { my $mm = sprintf "%02d", 3+7*($1 eq 'Oct'); print " <t:t dt=\"$4-$mm-$2T$3\" r=\"$4-$mm-$5T$6\"/>\n"; }'
Now, we need is a way to run the test suite. In XSLT terms, we say: "to apply a template to the test data". This template will just apply a date-time-Paris
call to each of the tests.
<xsltemplate match="t:t" xmlnst="test:date-time-Paris">
<xslcall-template name="tap:is">
<xslwith-param name="got">
<xslcall-template name="date-time-Paris">
<xslwith-param name="date-time-Z" select="@dt"/>
</xslcall-template>
</xslwith-param>
<xslwith-param name="expected" select="@r"/>
<xslwith-param name="name" select="@dt"/>
</xslcall-template>
</xsltemplate>
</xslstylesheet>
We now have a complete stylesheet that can be used as a library in other stylesheet. But we have not yet run the tests. Also you probably wonder what are this XML namespace test:tap
and the template called tap:is
.
test:tap
is just a small XSLT test framework that I wrote and the report test results following the Test Anything Protocol that is well known by Perl programmers. tap:is
is part of the test:tap
API and just check for equality and reports in the TAP format.
xml version="1.0" encoding="UTF-8"
<xslstylesheet version="1.0"
xmlnsxsl="http://www.w3.org/1999/XSL/Transform"
xmlnstap="test:tap"
>
<xsltemplate match="tap:suite">
<xslmessage>1..<xslvalue-of select="count(node()[name()!=''])"/></xslmessage>
<xslmessage># Suite: <xslvalue-of select="@name"/></xslmessage>
TODO
<xslapply-templates select="node()[name()!='']"/>
</xsltemplate>
<xsltemplate name="tap:is">
<xslparam name="got"/>
<xslparam name="expected"/>
<xslparam name="name"/>
<xslchoose>
<xslwhen test="$got = $expected">
<xslmessage>ok <xslvalue-of select="position()"/> - <xslvalue-of select="concat($name, ' -> ', $got)"/></xslmessage>
</xslwhen>
<xslotherwise>
<xslmessage>not ok <xslvalue-of select="position()"/> - <xslvalue-of select="$name"/></xslmessage>
<xslmessage># got: <xslvalue-of select="$got"/></xslmessage>
<xslmessage># expected: <xslvalue-of select="$expected"/></xslmessage>
</xslotherwise>
</xslchoose>
</xsltemplate>
</xslstylesheet>
We now have two libraries. Let's add a bit more XSLT to glue them together. The main stylesheet applies tap:suite
templates to all tap:suite
from all imported stylesheets.
xml version="1.0" encoding="UTF-8"
<xsltransform version="1.0"
xmlnsxsl="http://www.w3.org/1999/XSL/Transform"
>
<xslimport href="tap.xslt"/>
<xslimport href="date-time-Paris.xslt"/>
<xsltemplate match="/">
<xslfor-each select="document('')/xsl:transform/xsl:import/@href">
<xslapply-templates select="document(.)//tap:suite" xmlnstap="test:tap"/>
</xslfor-each>
</xsltemplate>
</xsltransform>
We now have everything to run the test suite:
$ echo '<x/>' | xsltproc tap-run.xslt -
1..15
# Suite: date-time-Paris
ok 1 - 2008-09-24T21:37:52Z -> 2008-09-24T23:37:52+02:00
ok 2 - 2008-09-24T23:37:52Z -> 2008-09-25T01:37:52+02:00
not ok 3 - 2008-09-24T23:37:52.325Z
# got: 2008-09-25T01:37:52.3249999999998+02:00
# expected: 2008-09-25T01:37:52.325+02:00
ok 4 - 2008-10-01T00:00:00Z -> 2008-10-01T02:00:00+02:00
ok 5 - 2008-10-26T00:59:59Z -> 2008-10-26T02:59:59+02:00
ok 6 - 2008-10-26T01:00:00Z -> 2008-10-26T02:00:00+01:00
ok 7 - 2008-10-31T12:00:00Z -> 2008-10-31T13:00:00+01:00
ok 8 - 2008-11-24T21:37:52Z -> 2008-11-24T22:37:52+01:00
ok 9 - 2008-12-31T23:30:00Z -> 2009-01-01T00:30:00+01:00
ok 10 - 2009-01-01T00:00:00Z -> 2009-01-01T01:00:00+01:00
ok 11 - 2009-03-01T12:00:00Z -> 2009-03-01T13:00:00+01:00
ok 12 - 2009-03-29T00:59:59Z -> 2009-03-29T01:59:59+01:00
ok 13 - 2009-03-29T01:00:00Z -> 2009-03-29T03:00:00+02:00
ok 14 - 2009-03-30T12:00:00Z -> 2009-03-30T14:00:00+02:00
ok 15 - 2009-07-01T00:00:00Z -> 2009-07-01T02:00:00+02:00
So now I know that I have a failing test due to rounding occuring. For my XMLTV project it is not important as I will not have to handle decimal seconds.