Monday 2 March 2009

Adjusting time zone, and xs:dateTime formating with XSLT 2

We're often asked to display and format dates in various formats using XSLT. Most of the time just formatting really, but less frequently we're asked to make time calculations, like for instance given a UTC (Coordinated Universal Time) time stamp, calculate the EST (US Eastern Standard Time) for that universal time stamp if the output is being rendered for a client within that time zone.

Other more complex operations might include adding or subtracting years, months, days, hours, minutes, seconds or even a time zone offset to a given time stamp. Surely that sounds fairly straight forward at first, but if you start thinking about when a month might end in 30, 31 or 29, you can start picturing the endless combinations you might have to account for if you were to write standard XSL 1.0 syntax to achieve this kind of calculations. If you are using XSLT 1.0 I would probably suggest to resolve date manipulation before the xml reaches the processor, as the support for this kind of algorithm is very poorly supported.

Let's say we have a universal time stamp for the 1st January 2009 at 3:00 am GMT, if you were to calculate the equivalent of that time stamp in EST (US Eastern Standard Time) you should output 31st December 2008 at 22:00 pm EST.

The code examples below will output the current system's UTC (Coordinated Universal Time) time in EST (US Eastern Standard Time).

Saxon 9.3/9.5

For these processors, all that's required is to adjust the time stamp to the EST (US Eastern Standard Time) time zone, by invoking a time duration of minus 5 hours. The adjust-dateTime-to-timezone() function will kindly then return an xs:dateTime instance of the same time stamp in EST (US Eastern Standard Time). Note that Saxon does the automatic conversion of the time stamp from a #TEXT node value in an element, to xs:dateTime data type, so you don't actually need to invoke xs:dateTime manually.

You then use adjust-dateTime-to-timezone() to move the date across the time zone spectrum. For this you'll need an xs:dayTimeDuration() to represent the amount of time corresponding to the time zone offset you are trying to adjust your time stamp to. In this case the offset is -5 hours from UTC/GMT, represented by -PT5H. xs:dayTimeDuration() not only performs the required calculations to subtract -5 hours from your time stamp, it will also add the relevant information to the time stamp signature to define it as in the appropriate time zone. For more on this function, have a look here http://www.w3.org/TR/xpath-functions/#func-adjust-date-to-timezone.


fn:adjust-date-to-timezone($arg as xs:date?) as xs:date?

fn:adjust-date-to-timezone(
$arg as xs:date?,
$timezone as xs:dayTimeDuration?
) as xs:date?

It is possible then to format the xs:dateTime to a format that suits your needs using format-dateTime(). This quite a comprehensive function, that uses a set of literal substrings known as "picture string" to define the display shape of your date time output. for example "[D1o] [MNn], [Y]" will produce "31st December, 2002".

One particularly difficult date formating detail to obtain with other functions is the time zone acronym, most people just end up hard coding it like when in XSLT 1, but [ZN,*-3] will output that nicely for you.

format-dateTime(
$value as xs:dateTime?,
$picture as xs:string,
$language as xs:string?,
$calendar as xs:string?,
$country as xs:string?
) as xs:string?


format-dateTime also offers another three parameters to help you further tune the output format to something more meaningful to the context of your output:
• Language - Specifies the language you want your date formatted as, for example en for english, fr for French and de for German. These are the ones I have tried successfully (see example below), although I am sure saxon supports many other languages.
• Calendar - Defines the type of calendar you need to use, for example ISO (ISO 8601 calendar), JE (Japanese Calendar), AH (Anno Hegirae "Muhammedan Era"), the default being AD (Anno Domini "Christian Era").
• Country - should give the location where the event in question took place, it's not the locale of the user of the information. (In fact, timezone formatting is the only thing Saxon uses it for; it was provided primarily for use with non-Gregorian calendars where the translation from Gregorian to another calendar may be location-dependent.). Time zone -5 never occurs in the UK, and we don't have a name for it; or rather, we call it EST if it happened in New York in winter, CDT if it happened in Chicago in summer, COT if it happened in Colombia, AST if it happened in Brazil, and so on. Hence the need to know where it happened.

For further information on format-dateTime() have a look at http://www.w3.org/TR/xslt20/#function-format-dateTime

You'll need to declare the XML Schema namespace, that in this processor version will also make available a mechanism to invoke instances of data types such as dayTimeDuration.

XSL

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
version="2.0"

exclude-result-prefixes="#all"
>
<xsl:output indent="yes"/>
<xsl:template match="/">
<xsl:variable name="utc-timestamp" select="current-dateTime()"/>

<xsl:variable name="gmt-timestamp" select="adjust-dateTime-to-timezone($utc-timestamp, xs:dayTimeDuration('PT0H'))"/>
<xsl:variable name="eu-timestamp" select="adjust-dateTime-to-timezone($utc-timestamp, xs:dayTimeDuration('PT1H'))"/>
<xsl:variable name="est-timestamp" select="adjust-dateTime-to-timezone($utc-timestamp, xs:dayTimeDuration('-PT5H'))"/>

<date timestamp="{$utc-timestamp}">
<gmt str="{$gmt-timestamp}">
<xsl:value-of select="format-dateTime($gmt-timestamp, '[D] [MN,*-3] [Y] [h]:[m01][PN,*-2] [ZN,*-3]', (), (), 'uk')"/>
</gmt>
<est str="{$est-timestamp}">
<xsl:value-of select="format-dateTime($est-timestamp, '[D] [MNn] [Y] [h]:[m01][PN,*-2] [ZN,*-3]', (), (), 'us')"/>
</est>
<cet str="{$eu-timestamp}">
<xsl:value-of select="format-dateTime($eu-timestamp, '[D] [MNn] [Y] [h]:[m01][PN,*-2] [ZN,*-3]', ('fr'), (), 'fr')"/>
</cet>
<cet str="{$eu-timestamp}">
<xsl:value-of select="format-dateTime($eu-timestamp, '[Dwo] [MNn] [Y] [h]:[m01][PN,*-2] [ZN,*-3]', 'de', (), 'de')"/>
</cet>
<cet str="{$eu-timestamp}">
<xsl:value-of select="format-dateTime($eu-timestamp, '[h].[m01][Pn] [ZN,*-3] [FNn], [D1o] [MNn] [Y]', ('sv'), (), 'sv')"/>
</cet>
</date>
</xsl:template>
</xsl:stylesheet>



Output

<?xml version="1.0" encoding="UTF-8"?>
<date timestamp="2009-03-02T17:55:13.47Z">
<gmt str="2009-03-02T17:55:13.47Z">2 MAR 2009 5:55PM GMT</gmt>
<est str="2009-03-02T12:55:13.47-05:00">2 March 2009 12:55PM EST</est>
<cet str="2009-03-02T18:55:13.47+01:00">2 Mars 2009 6:55PM CET</cet>
<cet str="2009-03-02T18:55:13.47+01:00">zweite März 2009 6:55PM CET</cet>
<cet str="2009-03-02T18:55:13.47+01:00">6.55p.m. CET måndag, 2 mars 2009</cet>
</date>


saxon 8.7
If you're using Saxon 3.7, the processor will throw a stylesheet compilation error it you try to invoke an xs:dayTimeDuration() after declaring the XML schema namespace, so you'll need to declare the XPath data types namespace instead for exactly the same purpose. It seams to work well, but be careful with the name space URI, as I found a thread where Michael Kay is saying saxon has to often change the namespace URI for new releases to comply to W3C's latest URI's. Chances are that if you're using other versions of saxon, you might have to adjust your namespace URI for this to work.


xmlns:xdt="http://www.w3.org/2005/02/xpath-datatypes"

<xsl:variable name="est-time" select="adjust-dateTime-to-timezone(current-dateTime(), xdt:dayTimeDuration('-PT5H'))"/>
<xsl:value-of select="format-dateTime($est-time, '[D] [MN,*-3] [Y] [h]:[m01][PN,*-2] [ZN,*-3]')"/>


If you're using an XSLT 1 processor and you're trying to implement similar behavior implemented it's awful and I wouldn't wish it upon my worst enemy. Have a look at the examples in the following link (http://geekswithblogs.net/workdog/archive/2007/02/08/105858.aspx), it just shows the amount of code required to get this functionality working using string manipulation, and simple arithmetic operations with standard XSLT 1 syntax.

If your environment doesn't support an XSLT 2 processor, I would advise you to review the source of your XML and perhaps deal with the time conversion from the middle tire. I know it's easier said than done, but if you have access to standard APIs that already do the job properly, why reinvent the will . . . badly.

One other final alternative would be to write yourself an XSLT extension in a language compatible with your processor, where you can define your own functions that will provide the desired functionality.