001/*
002 * (C) Copyright 2006-2012 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Nuxeo - initial API and implementation
018 */
019package org.nuxeo.ecm.core.schema.utils;
020
021import java.text.ParseException;
022import java.util.Calendar;
023import java.util.Date;
024import java.util.GregorianCalendar;
025import java.util.TimeZone;
026
027/**
028 * Parse / format ISO 8601 dates.
029 *
030 * @author "Stephane Lacoin [aka matic] <slacoin at nuxeo.com>"
031 */
032public class DateParser {
033
034    /**
035     * @since 8.2
036     */
037    public static final String W3C_DATE_FORMAT = "%04d-%02d-%02dT%02d:%02d:%02d.%02dZ";
038
039    public static Calendar parse(String str) throws ParseException {
040        if (str == null) {
041            return null;
042        }
043        Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
044        cal.clear();
045        int len = str.length();
046        if (len == 0) { // empty string
047            // TODO throw error?
048            return cal;
049        }
050        int i = 0;
051        i = readYear(cal, str, i);
052        i = readCharOpt('-', cal, str, i);
053        if (i == -1) {
054            return cal;
055        }
056        i = readMonth(cal, str, i);
057        i = readCharOpt('-', cal, str, i);
058        if (i == -1) {
059            return cal;
060        }
061        i = readDay(cal, str, i);
062        i = readCharOpt('T', cal, str, i);
063        if (i == -1) {
064            return cal;
065        }
066        i = readHours(cal, str, i);
067        i = readCharOpt(':', cal, str, i);
068        if (i == -1) {
069            return cal;
070        }
071        i = readMinutes(cal, str, i);
072        if (isChar(':', str, i)) {
073            i = readSeconds(cal, str, i + 1);
074            if (isChar('.', str, i)) {
075                i = readMilliseconds(cal, str, i + 1);
076            }
077        }
078        if (i > -1) {
079            readTimeZone(cal, str, i);
080        }
081        return cal;
082    }
083
084    public static Date parseW3CDateTime(String str) {
085        if (str == null || "".equals(str)) {
086            return null;
087        }
088        try {
089            return parse(str).getTime();
090        } catch (ParseException e) {
091            throw new IllegalArgumentException("Failed to parse ISO 8601 date: " + str, e);
092        }
093    }
094
095    /**
096     * 2011-10-23T12:00:00.00Z
097     *
098     * @param date
099     * @return
100     */
101    public static String formatW3CDateTime(Date date) {
102        if (date == null) {
103            return null;
104        }
105        Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
106        cal.setTime(date);
107        return formatW3CDateTime(cal);
108    }
109
110    /**
111     * 2011-10-23T12:00:00.00Z
112     *
113     * @param calendar
114     * @return
115     *
116     * @since 7.3
117     */
118    public static String formatW3CDateTime(Calendar calendar) {
119        if (calendar == null) {
120            return null;
121        }
122        return String.format(W3C_DATE_FORMAT, calendar.get(Calendar.YEAR),
123                calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.DATE),
124                calendar.get(Calendar.HOUR_OF_DAY),
125                calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND),
126                calendar.get(Calendar.MILLISECOND) / 10);
127    }
128
129    private final static int readYear(Calendar cal, String str, int off) throws ParseException {
130        if (str.length() >= off + 4) {
131            cal.set(Calendar.YEAR, Integer.parseInt(str.substring(off, off + 4)));
132            return off + 4;
133        }
134        throw new ParseException("Invalid year in date '" + str + "'", off);
135    }
136
137    private final static int readMonth(Calendar cal, String str, int off) throws ParseException {
138        if (str.length() >= off + 2) {
139            cal.set(Calendar.MONTH, Integer.parseInt(str.substring(off, off + 2)) - 1);
140            return off + 2;
141        }
142        throw new ParseException("Invalid month in date '" + str + "'", off);
143    }
144
145    private final static int readDay(Calendar cal, String str, int off) throws ParseException {
146        if (str.length() >= off + 2) {
147            cal.set(Calendar.DATE, Integer.parseInt(str.substring(off, off + 2)));
148            return off + 2;
149        }
150        throw new ParseException("Invalid day in date '" + str + "'", off);
151    }
152
153    private final static int readHours(Calendar cal, String str, int off) throws ParseException {
154        if (str.length() >= off + 2) {
155            cal.set(Calendar.HOUR, Integer.parseInt(str.substring(off, off + 2)));
156            return off + 2;
157        }
158        throw new ParseException("Invalid hours in date '" + str + "'", off);
159    }
160
161    private final static int readMinutes(Calendar cal, String str, int off) throws ParseException {
162        if (str.length() >= off + 2) {
163            cal.set(Calendar.MINUTE, Integer.parseInt(str.substring(off, off + 2)));
164            return off + 2;
165        }
166        throw new ParseException("Invalid minutes in date '" + str + "'", off);
167    }
168
169    private final static int readSeconds(Calendar cal, String str, int off) throws ParseException {
170        if (str.length() >= off + 2) {
171            cal.set(Calendar.SECOND, Integer.parseInt(str.substring(off, off + 2)));
172            return off + 2;
173        }
174        throw new ParseException("Invalid seconds in date '" + str + "'", off);
175    }
176
177    /**
178     * Return -1 if no more content to read or the offset of the expected TZ
179     *
180     * @param cal
181     * @param str
182     * @param off
183     * @return
184     * @throws ParseException
185     */
186    private final static int readMilliseconds(Calendar cal, String str, int off) throws ParseException {
187        int e = str.indexOf('Z', off);
188        if (e == -1) {
189            e = str.indexOf('+', off);
190            if (e == -1) {
191                e = str.indexOf('-', off);
192            }
193        }
194        String ms = e == -1 ? str.substring(off) : str.substring(off, e);
195        // need to normalize the ms fraction to 3 digits.
196        // If less than 3 digits right pad with 0
197        // If more than 3 digits truncate to 3 digits.
198        int mslen = ms.length();
199        if (mslen > 0) {
200            int f = 0;
201            switch (mslen) {
202            case 1:
203                f = Integer.parseInt(ms) * 100;
204                break;
205            case 2:
206                f = Integer.parseInt(ms) * 10;
207                break;
208            case 3:
209                f = Integer.parseInt(ms);
210                break;
211            default: // truncate
212                f = Integer.parseInt(ms.substring(0, 3));
213                break;
214            }
215            cal.set(Calendar.MILLISECOND, f);
216        }
217        return e;
218    }
219
220    private static final boolean isChar(char c, String str, int off) {
221        return str.length() > off && str.charAt(off) == c;
222    }
223
224    private static final int readCharOpt(char c, Calendar cal, String str, int off) {
225        if (str.length() > off) {
226            if (str.charAt(off) == c) {
227                return off + 1;
228            }
229        }
230        return -1;
231    }
232
233    private final static boolean readTimeZone(Calendar cal, String str, int off) throws ParseException {
234        int len = str.length();
235        if (len == off) {
236            return false;
237        }
238        char c = str.charAt(off);
239        if (c == 'Z') {
240            return true;
241        }
242        off++;
243        boolean plus = false;
244        if (c == '+') {
245            plus = true;
246        } else if (c != '-') {
247            throw new ParseException("Only Z, +, - prefixes are allowed in TZ", off);
248        }
249        int h = 0;
250        int m = 0;
251        int d = len - off;
252        /**
253         * We check here the different format of timezone * +02 (d=2 : doesn't seem to be in ISO-8601 but left for
254         * compat) * +02:00 (d=5) * +0200 (d=4)
255         */
256        if (d == 2) {
257            h = Integer.parseInt(str.substring(off, off + 2));
258        } else if (d == 5) {
259            h = Integer.parseInt(str.substring(off, off + 2));
260            m = Integer.parseInt(str.substring(off + 3, off + 5));
261            // we do not check for ':'. we assume it is in the correct format
262        } else if (d == 4) {
263            h = Integer.parseInt(str.substring(off, off + 2));
264            m = Integer.parseInt(str.substring(off + 2, off + 4));
265        } else {
266            throw new ParseException("Invalid TZ in \"" + str + "\"", off);
267        }
268
269        if (plus) {
270            cal.add(Calendar.HOUR, -h);
271            cal.add(Calendar.MINUTE, -m);
272        } else {
273            cal.add(Calendar.HOUR, h);
274            cal.add(Calendar.MINUTE, m);
275        }
276
277        return true;
278    }
279
280}