001    package biweekly.util;
002    
003    import java.text.DateFormat;
004    import java.text.ParseException;
005    import java.util.Date;
006    import java.util.TimeZone;
007    import java.util.regex.Matcher;
008    import java.util.regex.Pattern;
009    
010    /*
011     Copyright (c) 2013, Michael Angstadt
012     All rights reserved.
013    
014     Redistribution and use in source and binary forms, with or without
015     modification, are permitted provided that the following conditions are met: 
016    
017     1. Redistributions of source code must retain the above copyright notice, this
018     list of conditions and the following disclaimer. 
019     2. Redistributions in binary form must reproduce the above copyright notice,
020     this list of conditions and the following disclaimer in the documentation
021     and/or other materials provided with the distribution. 
022    
023     THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
024     ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
025     WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
026     DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
027     ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
028     (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
029     LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
030     ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
031     (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
032     SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
033     */
034    
035    /**
036     * Helper class that formats and parses iCalendar dates. iCalendar dates adhere
037     * to the ISO8601 date format standard.
038     * @author Michael Angstadt
039     */
040    public class ICalDateFormatter {
041            /**
042             * Regular expression used to parse timezone offset strings.
043             */
044            private static final Pattern timeZoneRegex = Pattern.compile("^([-\\+])?(\\d{1,2})(:?(\\d{2}))?$");
045    
046            /**
047             * Formats a date for inclusion in an iCalendar object.
048             * @param date the date to format
049             * @param format the format to use
050             * @return the formatted date
051             */
052            public static String format(Date date, ISOFormat format) {
053                    return format(date, format, null);
054            }
055    
056            /**
057             * Formats a date for inclusion in an iCalendar object.
058             * @param date the date to format
059             * @param format the format to use
060             * @param timeZone the timezone to format the date in or null to use the
061             * JVM's default timezone (ignored with "UTC" formats)
062             * @return the formatted date
063             */
064            public static String format(Date date, ISOFormat format, TimeZone timeZone) {
065                    switch (format) {
066                    case UTC_TIME_BASIC:
067                    case UTC_TIME_EXTENDED:
068                            timeZone = TimeZone.getTimeZone("UTC");
069                            break;
070                    }
071    
072                    DateFormat df = format.getFormatDateFormat();
073                    if (timeZone != null) {
074                            df.setTimeZone(timeZone);
075                    }
076                    String str = df.format(date);
077    
078                    switch (format) {
079                    case TIME_EXTENDED:
080                            //add a colon to the timezone
081                            //example: converts "2012-07-05T22:31:41-0400" to "2012-07-05T22:31:41-04:00"
082                            str = str.replaceAll("([-\\+]\\d{2})(\\d{2})$", "$1:$2");
083                            break;
084                    }
085    
086                    return str;
087            }
088    
089            /**
090             * Parses an iCalendar date.
091             * @param dateStr the date string to parse (e.g. "20130609T181023Z")
092             * @return the parsed date
093             * @throws IllegalArgumentException if the date string isn't in one of the
094             * accepted ISO8601 formats
095             */
096            public static Date parse(String dateStr) {
097                    return parse(dateStr, null);
098            }
099    
100            /**
101             * Parses an iCalendar date.
102             * @param dateStr the date string to parse (e.g. "20130609T181023Z")
103             * @param timezone the timezone to parse the date as or null to use the
104             * JVM's default timezone (if the date string contains its own timezone,
105             * then that timezone will be used instead)
106             * @return the parsed date
107             * @throws IllegalArgumentException if the date string isn't in one of the
108             * accepted ISO8601 formats
109             */
110            public static Date parse(String dateStr, TimeZone timezone) {
111                    //find out what ISOFormat the date is in
112                    ISOFormat format = null;
113                    for (ISOFormat f : ISOFormat.values()) {
114                            if (f.matches(dateStr)) {
115                                    format = f;
116                                    break;
117                            }
118                    }
119                    if (format == null) {
120                            throw new IllegalArgumentException("Date string is not in a valid ISO-8601 format.");
121                    }
122    
123                    //tweak the date string to make it work with SimpleDateFormat
124                    switch (format) {
125                    case TIME_EXTENDED:
126                    case HCARD_TIME_TAG:
127                            //SimpleDateFormat doesn't recognize timezone offsets that have colons
128                            //so remove the colon from the timezone offset
129                            dateStr = dateStr.replaceAll("([-\\+]\\d{2}):(\\d{2})$", "$1$2");
130                            break;
131                    case UTC_TIME_BASIC:
132                    case UTC_TIME_EXTENDED:
133                            //SimpleDateFormat doesn't recognize "Z"
134                            dateStr = dateStr.replace("Z", "+0000");
135                            break;
136                    }
137    
138                    //parse the date
139                    DateFormat df = format.getParseDateFormat();
140                    if (timezone != null) {
141                            df.setTimeZone(timezone);
142                    }
143                    try {
144                            return df.parse(dateStr);
145                    } catch (ParseException e) {
146                            //should never be thrown because the string is checked against a regex
147                            throw new IllegalArgumentException("Date string is not in a valid ISO-8601 format.");
148                    }
149            }
150    
151            /**
152             * Parses a timezone that's in ISO8601 format.
153             * @param offsetStr the timezone offset string (e.g. "-0500" or "-05:00")
154             * @return the hour offset (index 0) and the minute offset (index 1)
155             * @throws IllegalArgumentException if the timezone string isn't in the
156             * right format
157             */
158            public static int[] parseTimeZone(String offsetStr) {
159                    Matcher m = timeZoneRegex.matcher(offsetStr);
160    
161                    if (!m.find()) {
162                            throw new IllegalArgumentException("Offset string is not in ISO8610 format: " + offsetStr);
163                    }
164    
165                    String sign = m.group(1);
166                    boolean positive;
167                    if ("-".equals(sign)) {
168                            positive = false;
169                    } else {
170                            positive = true;
171                    }
172    
173                    String hourStr = m.group(2);
174                    int hourOffset = Integer.parseInt(hourStr);
175                    if (!positive) {
176                            hourOffset *= -1;
177                    }
178    
179                    String minuteStr = m.group(4);
180                    int minuteOffset = (minuteStr == null) ? 0 : Integer.parseInt(minuteStr);
181    
182                    return new int[] { hourOffset, minuteOffset };
183            }
184    
185            /**
186             * Formats a {@link TimeZone} object according to ISO8601 rules.
187             * 
188             * @param timeZone the timezone to format
189             * @param extended true to use "extended" format, false not to. Extended
190             * format will put a colon between the hour and minute.
191             * @return the formatted timezone (e.g. "+0530" or "+05:30")
192             */
193            public static String formatTimeZone(TimeZone timeZone, boolean extended) {
194                    int hours = timeZone.getRawOffset() / 1000 / 60 / 60;
195                    int minutes = Math.abs((timeZone.getRawOffset() / 1000) / 60) % 60;
196                    return formatTimeZone(hours, minutes, extended);
197            }
198    
199            /**
200             * Formats a timezone offset according to ISO8601 rules.
201             * 
202             * @param hourOffset the hour offset
203             * @param minuteOffset the minute offset (between 0 and 59)
204             * @param extended true to use "extended" format, false not to. Extended
205             * format will put a colon between the hour and minute.
206             * @return the formatted timezone (e.g. "+0530" or "+05:30")
207             */
208            public static String formatTimeZone(int hourOffset, int minuteOffset, boolean extended) {
209                    StringBuilder sb = new StringBuilder();
210                    boolean positive = hourOffset >= 0;
211    
212                    sb.append(positive ? '+' : '-');
213    
214                    hourOffset = Math.abs(hourOffset);
215                    if (hourOffset < 10) {
216                            sb.append('0');
217                    }
218                    sb.append(hourOffset);
219    
220                    if (extended) {
221                            sb.append(':');
222                    }
223    
224                    if (minuteOffset < 10) {
225                            sb.append('0');
226                    }
227                    sb.append(minuteOffset);
228    
229                    return sb.toString();
230            }
231    
232            /**
233             * Gets the {@link TimeZone} object that corresponds to the given ID.
234             * @param timezoneId the timezone ID (e.g. "America/New_York")
235             * @return the timezone object or null if not found
236             */
237            public static TimeZone parseTimeZoneId(String timezoneId) {
238                    TimeZone timezone = TimeZone.getTimeZone(timezoneId);
239                    return "GMT".equals(timezone.getID()) ? null : timezone;
240            }
241    
242            private ICalDateFormatter() {
243                    //hide constructor
244            }
245    }