View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.onehippo.forge.utilities.commons.jcrmockup;
18  
19  import java.text.DecimalFormat;
20  import java.text.ParseException;
21  import java.util.Calendar;
22  import java.util.GregorianCalendar;
23  import java.util.TimeZone;
24  
25  import org.slf4j.Logger;
26  import org.slf4j.LoggerFactory;
27  
28  /**
29   * @see org.apache.jackrabbit.util.ISO8601
30   *
31   * The <code>ISO8601</code> utility class provides helper methods
32   * to deal with date/time formatting using a specific ISO8601-compliant
33   * format (see <a href="http://www.w3.org/TR/NOTE-datetime">ISO 8601</a>).
34   *
35   * The currently supported format is:
36   * <pre>
37   *   &plusmn;YYYY-MM-DDThh:mm:ss.SSSTZD
38   * </pre>
39   * where:
40   * <pre>
41   *   YYYY = four-digit year with optional sign where values &le; 0 are
42   *           denoting years BCE and values &gt; 0 are denoting years CE,
43   *           e.g. -0001 denotes the year 2 BCE, 0000 denotes the year 1 BCE,
44   *           0001 denotes the year 1 CE, and so on...
45   *   MM    = two-digit month (01=January, etc.)
46   *   DD    = two-digit day of month (01 through 31)
47   *   hh    = two digits of hour (00 through 23) (am/pm NOT allowed)
48   *   mm    = two digits of minute (00 through 59)
49   *   ss    = two digits of second (00 through 59)
50   *   SSS   = three digits of milliseconds (000 through 999)
51   *   TZD   = time zone designator, Z for Zulu (i.e. UTC) or an offset from UTC
52   *           in the form of +hh:mm or -hh:mm
53   * </pre>
54   */
55  public final class ISO8601 {
56      /**
57       * misc. numeric formats used in formatting
58       */
59      private static final DecimalFormat XX_FORMAT = new DecimalFormat("00");
60      private static final DecimalFormat XXX_FORMAT = new DecimalFormat("000");
61      private static final DecimalFormat XXXX_FORMAT = new DecimalFormat("0000");
62  
63      private static final int YEAR_LENGTH = 4;
64      private static final int MONTH_LENGTH = 2;
65      private static final int DAY_LENGTH = 2;
66      private static final int HOUR_LENGTH = 2;
67      private static final int MINUTE_LENGTH = 2;
68      private static final int SECOND_LENGTH = 2;
69      private static final int MILLISECOND_LENGTH = 3;
70      private static final int MILLISECONDS_PER_SECOND = 1000;
71      private static final int SECONDS_PER_MINUTE = 60;
72      private static final int MINUTES_PER_HOUR = 60;
73      private static final int MAX_FOUR_DIGIT_NUMBER = 9999;
74  
75      private static final Logger log = LoggerFactory.getLogger(ISO8601.class);
76  
77      private ISO8601() {
78          // prevent instantiation
79      }
80  
81      /**
82       * Parses an ISO8601-compliant date/time string.
83       *
84       * note that we cannot use java.text.SimpleDateFormat for
85       * parsing because it cannot handle years &le; 0 and TZD's
86       *
87       * @param text the date/time string to be parsed
88       * @return a <code>Calendar</code>, or <code>null</code> if the input could
89       *         not be parsed
90       * @throws IllegalArgumentException if a <code>null</code> argument is passed
91       */
92      public static Calendar parse(String text) {
93          if (text == null) {
94              throw new IllegalArgumentException("argument can not be null");
95          }
96  
97          DateTimeParser parser = new DateTimeParser(text);
98  
99          char sign;
100         int year, month, day, hour, min, sec, ms;
101         TimeZone tz;
102 
103         try {
104             sign = parser.parseSign();
105             year = parser.parseInt(YEAR_LENGTH);
106             parser.parseDelimiter('-');
107             month = parser.parseInt(MONTH_LENGTH);
108             parser.parseDelimiter('-');
109             day = parser.parseInt(DAY_LENGTH);
110             parser.parseDelimiter('T');
111             hour = parser.parseInt(HOUR_LENGTH);
112             parser.parseDelimiter(':');
113             min = parser.parseInt(MINUTE_LENGTH);
114             parser.parseDelimiter(':');
115             sec = parser.parseInt(SECOND_LENGTH);
116             parser.parseDelimiter('.');
117             ms = parser.parseInt(MILLISECOND_LENGTH);
118             tz = parser.parseTimeZone();
119         } catch (IndexOutOfBoundsException e) {
120             log.debug("Could not parse'" + text + "'", e);
121             return null;
122         } catch (NumberFormatException e) {
123             log.debug("Could not parse '" + text + "'", e);
124             return null;
125         } catch (ParseException e) {
126             log.debug("Could not parse '" + text + "'", e);
127             return null;
128         }
129 
130         // initialize Calendar object
131         Calendar cal = Calendar.getInstance(tz);
132         cal.setLenient(false);
133         // year and era
134         if (sign == '-' || year == 0) {
135             // not CE, need to set era (BCE) and adjust year
136             cal.set(Calendar.YEAR, year + 1);
137             cal.set(Calendar.ERA, GregorianCalendar.BC);
138         } else {
139             cal.set(Calendar.YEAR, year);
140             cal.set(Calendar.ERA, GregorianCalendar.AD);
141         }
142         // month (0-based!)
143         cal.set(Calendar.MONTH, month - 1);
144         // day of month
145         cal.set(Calendar.DAY_OF_MONTH, day);
146         // hour
147         cal.set(Calendar.HOUR_OF_DAY, hour);
148         // minute
149         cal.set(Calendar.MINUTE, min);
150         // second
151         cal.set(Calendar.SECOND, sec);
152         // millisecond
153         cal.set(Calendar.MILLISECOND, ms);
154 
155         try {
156             /**
157              * the following call will trigger an IllegalArgumentException
158              * if any of the set values are illegal or out of range
159              */
160             cal.getTime();
161         } catch (IllegalArgumentException e) {
162             return null;
163         }
164 
165         return cal;
166     }
167 
168     /**
169      * Formats a <code>Calendar</code> value into an ISO8601-compliant
170      * date/time string.
171      *
172      * @param cal the time value to be formatted into a date/time string.
173      * @return the formatted date/time string.
174      * @throws IllegalArgumentException if a <code>null</code> argument is passed
175      * or the calendar cannot be represented as defined by ISO 8601 (i.e. year
176      * with more than four digits).
177      */
178     public static String format(Calendar cal) {
179         if (cal == null) {
180             throw new IllegalArgumentException("argument can not be null");
181         }
182 
183         /**
184          * the format of the date/time string is:
185          * YYYY-MM-DDThh:mm:ss.SSSTZD
186          *
187          * note that we cannot use java.text.SimpleDateFormat for
188          * formatting because it can't handle years <= 0 and TZD's
189          */
190         StringBuffer buf = new StringBuffer();
191         // year ([-]YYYY)
192         buf.append(XXXX_FORMAT.format(getYear(cal)));
193         buf.append('-');
194         // month (MM)
195         buf.append(XX_FORMAT.format(cal.get(Calendar.MONTH) + 1));
196         buf.append('-');
197         // day (DD)
198         buf.append(XX_FORMAT.format(cal.get(Calendar.DAY_OF_MONTH)));
199         buf.append('T');
200         // hour (hh)
201         buf.append(XX_FORMAT.format(cal.get(Calendar.HOUR_OF_DAY)));
202         buf.append(':');
203         // minute (mm)
204         buf.append(XX_FORMAT.format(cal.get(Calendar.MINUTE)));
205         buf.append(':');
206         // second (ss)
207         buf.append(XX_FORMAT.format(cal.get(Calendar.SECOND)));
208         buf.append('.');
209         // millisecond (SSS)
210         buf.append(XXX_FORMAT.format(cal.get(Calendar.MILLISECOND)));
211         // time zone designator (Z or +00:00 or -00:00)
212         TimeZone tz = cal.getTimeZone();
213         // determine offset of timezone from UTC (incl. daylight saving)
214         int offset = tz.getOffset(cal.getTimeInMillis());
215         if (offset != 0) {
216             int hours = Math.abs((offset / (SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND)) / MINUTES_PER_HOUR);
217             int minutes = Math.abs((offset / (SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND)) % MINUTES_PER_HOUR);
218             buf.append(offset < 0 ? '-' : '+');
219             buf.append(XX_FORMAT.format(hours));
220             buf.append(':');
221             buf.append(XX_FORMAT.format(minutes));
222         } else {
223             buf.append('Z');
224         }
225         return buf.toString();
226     }
227 
228     /**
229      * Returns the astonomical year of the given calendar.
230      *
231      * @param cal a calendar instance.
232      * @return the astronomical year.
233      * @throws IllegalArgumentException if calendar cannot be represented as
234      *                                  defined by ISO 8601 (i.e. year with more
235      *                                  than four digits).
236      */
237     public static int getYear(Calendar cal) {
238         // determine era and adjust year if necessary
239         int year = cal.get(Calendar.YEAR);
240         if (cal.isSet(Calendar.ERA)
241                 && cal.get(Calendar.ERA) == GregorianCalendar.BC) {
242             /**
243              * calculate year using astronomical system:
244              * year n BCE => astronomical year -n + 1
245              */
246             year = 0 - year + 1;
247         }
248 
249         if (year > MAX_FOUR_DIGIT_NUMBER || year < -MAX_FOUR_DIGIT_NUMBER) {
250             throw new IllegalArgumentException("Calendar has more than four " +
251                     "year digits, cannot be formatted as ISO8601: " + year);
252         }
253         return year;
254     }
255 
256     private static class DateTimeParser {
257 
258         private final String text;
259         private int offset;
260 
261         DateTimeParser(String text) {
262             this.text = text;
263             offset = 0;
264         }
265 
266         char parseSign() {
267             char sign = text.charAt(offset);
268 
269             if (sign == '-' || sign == '+') {
270                 offset++;
271                 return sign;
272             } else {
273                 // no sign specified, implied '+'
274                 return '+';
275             }
276         }
277 
278         int parseInt(int length) {
279             int result = Integer.parseInt(text.substring(offset, offset + length));
280             offset += length;
281             return result;
282         }
283 
284         void parseDelimiter(char expected) throws ParseException {
285             if (text.charAt(offset) != expected) {
286                 throw new ParseException("Expected delimiter '" + expected + "'", offset);
287             }
288             offset++;
289         }
290 
291         TimeZone parseTimeZone() throws ParseException {
292             // time zone designator (Z or +00:00 or -00:00)
293             final char sign = text.charAt(offset);
294             String tzId;
295 
296             if (sign == '+' || sign == '-') {
297                 // offset to UTC specified in the format +00:00/-00:00
298                 tzId = "GMT" + text.substring(offset);
299             } else if (sign == 'Z') {
300                 tzId = "GMT";
301             } else {
302                 throw new ParseException("Invalid time zone, cannot start with '" + sign + "'", offset);
303             }
304 
305             TimeZone tz = TimeZone.getTimeZone(tzId);
306 
307             // verify id of returned time zone (getTimeZone defaults to "GMT")
308             if (!tz.getID().equals(tzId)) {
309                 // invalid time zone
310                 throw new ParseException("Invalid time zone: '" + tzId + "'", offset);
311             }
312 
313             return tz;
314         }
315 
316     }
317 
318 }