001    package ezvcard.util;
002    
003    import java.text.DecimalFormat;
004    import java.text.NumberFormat;
005    import java.util.Arrays;
006    import java.util.regex.Matcher;
007    import java.util.regex.Pattern;
008    
009    /*
010     Copyright (c) 2013, Michael Angstadt
011     All rights reserved.
012    
013     Redistribution and use in source and binary forms, with or without
014     modification, are permitted provided that the following conditions are met: 
015    
016     1. Redistributions of source code must retain the above copyright notice, this
017     list of conditions and the following disclaimer. 
018     2. Redistributions in binary form must reproduce the above copyright notice,
019     this list of conditions and the following disclaimer in the documentation
020     and/or other materials provided with the distribution. 
021    
022     THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
023     ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
024     WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
025     DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
026     ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
027     (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
028     LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
029     ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
030     (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
031     SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
032    
033     The views and conclusions contained in the software and documentation are those
034     of the authors and should not be interpreted as representing official policies, 
035     either expressed or implied, of the FreeBSD Project.
036     */
037    
038    /**
039     * <p>
040     * Represents a date in which some of the components are missing. This is used
041     * to represent reduced accuracy and truncated dates, as defined in ISO8601.
042     * </p>
043     * <p>
044     * A <b>truncated date</b> is a date where the "lesser" components are missing.
045     * For example, "12:30" is truncated because the "seconds" component is missing.
046     * 
047     * <pre class="brush:java">
048     * PartialDate date = PartialDate.time(12, 30, null);
049     * </pre>
050     * 
051     * </p>
052     * <p>
053     * A <b>reduced accuracy date</b> is a date where the "greater" components are
054     * missing. For example, "April 20" is reduced accuracy because the "year"
055     * component is missing.
056     * 
057     * <pre class="brush:java">
058     * PartialDate date = PartialDate.date(null, 4, 20);
059     * </pre>
060     * 
061     * </p>
062     * @author Michael Angstadt
063     */
064    public final class PartialDate {
065            private static final int SKIP = -1;
066            private static final int YEAR = 0;
067            private static final int MONTH = 1;
068            private static final int DATE = 2;
069            private static final int HOUR = 3;
070            private static final int MINUTE = 4;
071            private static final int SECOND = 5;
072            private static final int TIMEZONE_HOUR = 6;
073            private static final int TIMEZONE_MINUTE = 7;
074    
075            //@formatter:off
076            private static final Format dateFormats[] = new Format[] {
077                    new Format("(\\d{4})", YEAR),
078                    new Format("(\\d{4})-(\\d{2})", YEAR, MONTH),
079                    new Format("(\\d{4})-?(\\d{2})-?(\\d{2})", YEAR, MONTH, DATE),
080                    new Format("--(\\d{2})-?(\\d{2})", MONTH, DATE),
081                    new Format("--(\\d{2})", MONTH),
082                    new Format("---(\\d{2})", DATE)
083            };
084            //@formatter:on
085    
086            private static final String timezoneRegex = "(([-+]\\d{1,2}):?(\\d{2})?)?";
087    
088            //@formatter:off
089            private static final Format timeFormats[] = new Format[] {
090                    new Format("(\\d{2})" + timezoneRegex, HOUR, SKIP, TIMEZONE_HOUR, TIMEZONE_MINUTE),
091                    new Format("(\\d{2}):?(\\d{2})" + timezoneRegex, HOUR, MINUTE, SKIP, TIMEZONE_HOUR, TIMEZONE_MINUTE),
092                    new Format("(\\d{2}):?(\\d{2}):?(\\d{2})" + timezoneRegex, HOUR, MINUTE, SECOND, SKIP, TIMEZONE_HOUR, TIMEZONE_MINUTE),
093                    new Format("-(\\d{2}):?(\\d{2})" + timezoneRegex, MINUTE, SECOND, SKIP, TIMEZONE_HOUR, TIMEZONE_MINUTE),
094                    new Format("-(\\d{2})" + timezoneRegex, MINUTE, SKIP, TIMEZONE_HOUR, TIMEZONE_MINUTE),
095                    new Format("--(\\d{2})" + timezoneRegex, SECOND, SKIP, TIMEZONE_HOUR, TIMEZONE_MINUTE)
096            };
097            //@formatter:on
098    
099            //package-private for unit testing
100            final Integer[] components = new Integer[8];
101    
102            /**
103             * <p>
104             * Creates a partial date containing only date components.
105             * </p>
106             * <p>
107             * The following combinations are not allowed and will result in an
108             * {@link IllegalArgumentException} being thrown:
109             * </p>
110             * <ul>
111             * <li>year, date (month missing)</li>
112             * </ul>
113             * @param year the year or null to exclude
114             * @param month the month or null to exclude
115             * @param date the day of the month or null to exclude
116             * @return the partial date
117             * @throws IllegalArgumentException if an invalid combination is entered or
118             * a component value is invalid (e.g. a negative month)
119             */
120            public static PartialDate date(Integer year, Integer month, Integer date) {
121                    return new PartialDate(year, month, date, null, null, null, null);
122            }
123    
124            /**
125             * <p>
126             * Creates a partial date containing only time components.
127             * </p>
128             * <p>
129             * The following combinations are not allowed and will result in an
130             * {@link IllegalArgumentException} being thrown:
131             * </p>
132             * <ul>
133             * <li>hour, second (minute missing)</li>
134             * </ul>
135             * @param hour the hour or null to exclude
136             * @param minute the minute or null to exclude
137             * @param second the second or null to exclude
138             * @return the partial date
139             * @throws IllegalArgumentException if an invalid combination is entered or
140             * a component value is invalid (e.g. a negative minute)
141             */
142            public static PartialDate time(Integer hour, Integer minute, Integer second) {
143                    return time(hour, minute, second, null);
144            }
145    
146            /**
147             * <p>
148             * Creates a partial date containing only time components.
149             * </p>
150             * <p>
151             * The following combinations are not allowed and will result in an
152             * {@link IllegalArgumentException} being thrown:
153             * </p>
154             * <ul>
155             * <li>hour, second (minute missing)</li>
156             * <li>timezoneMinute (timezoneHour missing)</li>
157             * </ul>
158             * @param hour the hour or null to exclude
159             * @param minute the minute or null to exclude
160             * @param second the second or null to exclude
161             * @param offset the UTC offset or null to exclude
162             * @return the partial date
163             * @throws IllegalArgumentException if an invalid combination is entered or
164             * a component value is invalid (e.g. a negative minute)
165             */
166            public static PartialDate time(Integer hour, Integer minute, Integer second, UtcOffset offset) {
167                    return new PartialDate(null, null, null, hour, minute, second, offset);
168            }
169    
170            /**
171             * <p>
172             * Creates a partial date containing date and time components, without a
173             * timezone.
174             * </p>
175             * <p>
176             * The following combinations are not allowed and will result in an
177             * {@link IllegalArgumentException} being thrown:
178             * </p>
179             * <ul>
180             * <li>year, date (month missing)</li>
181             * <li>hour, second (minute missing)</li>
182             * </ul>
183             * @param year the year or null to exclude
184             * @param month the month or null to exclude
185             * @param date the day of the month or null to exclude
186             * @param hour the hour or null to exclude
187             * @param minute the minute or null to exclude
188             * @param second the second or null to exclude
189             * @return the partial date
190             * @throws IllegalArgumentException if an invalid combination is entered or
191             * a component value is invalid (e.g. a negative minute)
192             */
193            public static PartialDate dateTime(Integer year, Integer month, Integer date, Integer hour, Integer minute, Integer second) {
194                    return dateTime(year, month, date, hour, minute, second, null);
195            }
196    
197            /**
198             * <p>
199             * Creates a partial date containing date and time components.
200             * </p>
201             * <p>
202             * The following combinations are not allowed and will result in an
203             * {@link IllegalArgumentException} being thrown:
204             * </p>
205             * <ul>
206             * <li>year, date (month missing)</li>
207             * <li>hour, second (minute missing)</li>
208             * <li>timezoneMinute (timezoneHour missing)</li>
209             * </ul>
210             * @param year the year or null to exclude
211             * @param month the month or null to exclude
212             * @param date the day of the month or null to exclude
213             * @param hour the hour or null to exclude
214             * @param minute the minute or null to exclude
215             * @param second the second or null to exclude
216             * @param offset the UTC offset or null to exclude
217             * @return the partial date
218             * @throws IllegalArgumentException if an invalid combination is entered or
219             * a component value is invalid (e.g. a negative minute)
220             */
221            public static PartialDate dateTime(Integer year, Integer month, Integer date, Integer hour, Integer minute, Integer second, UtcOffset offset) {
222                    return new PartialDate(year, month, date, hour, minute, second, offset);
223            }
224    
225            /**
226             * <p>
227             * Creates a new partial date.
228             * </p>
229             * <p>
230             * The following combinations are not allowed and will result in an
231             * {@link IllegalArgumentException} being thrown:
232             * </p>
233             * <ul>
234             * <li>year, date (month missing)</li>
235             * <li>hour, second (minute missing)</li>
236             * <li>timezoneMinute (timezoneHour missing)</li>
237             * </ul>
238             * @param year the year or null to exclude
239             * @param month the month or null to exclude
240             * @param date the day of the month or null to exclude
241             * @param hour the hour or null to exclude
242             * @param minute the minute or null to exclude
243             * @param second the second or null to exclude
244             * @param offset the UTC offset or null to exclude
245             * @throws IllegalArgumentException if an invalid combination is entered or
246             * a component value is invalid (e.g. a negative minute)
247             */
248            public PartialDate(Integer year, Integer month, Integer date, Integer hour, Integer minute, Integer second, UtcOffset offset) {
249                    //check for illegal values
250                    if (month != null && (month < 1 || month > 12)) {
251                            throw new IllegalArgumentException("Month must be between 1 and 12 inclusive.");
252                    }
253                    if (date != null && (date < 1 || date > 31)) {
254                            throw new IllegalArgumentException("Date must be between 1 and 31 inclusive.");
255                    }
256                    if (hour != null && (hour < 0 || hour > 23)) {
257                            throw new IllegalArgumentException("Hour must be between 0 and 23 inclusive.");
258                    }
259                    if (minute != null && (minute < 0 || minute > 59)) {
260                            throw new IllegalArgumentException("Minute must be between 0 and 59 inclusive.");
261                    }
262                    if (second != null && (second < 0 || second > 59)) {
263                            throw new IllegalArgumentException("Second must be between 0 and 59 inclusive.");
264                    }
265                    if (offset != null && (offset.getMinute() < 0 || offset.getMinute() > 59)) {
266                            throw new IllegalArgumentException("Timezone minute must be between 0 and 59 inclusive.");
267                    }
268    
269                    //check for illegal combinations
270                    if (year != null && month == null && date != null) {
271                            throw new IllegalArgumentException("Invalid date component combination: year, date");
272                    }
273                    if (hour != null && minute == null && second != null) {
274                            throw new IllegalArgumentException("Invalid time component combination: hour, second");
275                    }
276    
277                    //assign values
278                    components[YEAR] = year;
279                    components[MONTH] = month;
280                    components[DATE] = date;
281                    components[HOUR] = hour;
282                    components[MINUTE] = minute;
283                    components[SECOND] = second;
284                    components[TIMEZONE_HOUR] = (offset == null) ? null : offset.getHour();
285                    components[TIMEZONE_MINUTE] = (offset == null) ? null : offset.getMinute();
286            }
287    
288            /**
289             * Parses a partial date from a string.
290             * @param string the string (e.g. "--0420T15")
291             */
292            public PartialDate(String string) {
293                    String split[] = string.split("T");
294                    boolean success;
295                    if (split.length == 1) {
296                            //date or time
297                            success = parseDate(string) || parseTime(string);
298                    } else if (split[0].length() == 0) {
299                            //time
300                            success = parseTime(split[1]);
301                    } else {
302                            //date and time
303                            success = parseDate(split[0]) && parseTime(split[1]);
304                    }
305    
306                    if (!success) {
307                            throw new IllegalArgumentException("Could not parse date: " + string);
308                    }
309            }
310    
311            private boolean parseDate(String value) {
312                    for (Format regex : dateFormats) {
313                            if (regex.parse(this, value)) {
314                                    return true;
315                            }
316                    }
317                    return false;
318            }
319    
320            private boolean parseTime(String value) {
321                    for (Format regex : timeFormats) {
322                            if (regex.parse(this, value)) {
323                                    return true;
324                            }
325                    }
326                    return false;
327            }
328    
329            /**
330             * Gets the year component.
331             * @return the year component or null if not set
332             */
333            public Integer getYear() {
334                    return components[YEAR];
335            }
336    
337            private boolean hasYear() {
338                    return getYear() != null;
339            }
340    
341            /**
342             * Gets the month component.
343             * @return the month component or null if not set
344             */
345            public Integer getMonth() {
346                    return components[MONTH];
347            }
348    
349            private boolean hasMonth() {
350                    return getMonth() != null;
351            }
352    
353            /**
354             * Gets the date component.
355             * @return the date component or null if not set
356             */
357            public Integer getDate() {
358                    return components[DATE];
359            }
360    
361            private boolean hasDate() {
362                    return getDate() != null;
363            }
364    
365            /**
366             * Gets the hour component.
367             * @return the hour component or null if not set
368             */
369            public Integer getHour() {
370                    return components[HOUR];
371            }
372    
373            private boolean hasHour() {
374                    return getHour() != null;
375            }
376    
377            /**
378             * Gets the minute component.
379             * @return the minute component or null if not set
380             */
381            public Integer getMinute() {
382                    return components[MINUTE];
383            }
384    
385            private boolean hasMinute() {
386                    return getMinute() != null;
387            }
388    
389            /**
390             * Gets the second component.
391             * @return the second component or null if not set
392             */
393            public Integer getSecond() {
394                    return components[SECOND];
395            }
396    
397            private boolean hasSecond() {
398                    return getSecond() != null;
399            }
400    
401            /**
402             * Gets the timezone component.
403             * @return the timezone component (index 0 = hour, index 1 = minute) or null
404             * if not set
405             */
406            public Integer[] getTimezone() {
407                    if (!hasTimezone()) {
408                            return null;
409                    }
410                    return new Integer[] { components[TIMEZONE_HOUR], components[TIMEZONE_MINUTE] };
411            }
412    
413            private boolean hasTimezone() {
414                    return components[TIMEZONE_HOUR] != null; //minute component is optional
415            }
416    
417            /**
418             * Determines if there are any date components.
419             * @return true if it has at least one date component, false if not
420             */
421            public boolean hasDateComponent() {
422                    return hasYear() || hasMonth() || hasDate();
423            }
424    
425            /**
426             * Determines if there are any time components.
427             * @return true if there is at least one time component, false if not
428             */
429            public boolean hasTimeComponent() {
430                    return hasHour() || hasMinute() || hasSecond();
431            }
432    
433            /**
434             * Converts this partial date to its ISO 8601 representation.
435             * @param extended true to use extended format, false to use basic
436             * @return the ISO 8601 representation
437             */
438            public String toDateAndOrTime(boolean extended) {
439                    StringBuilder sb = new StringBuilder();
440                    NumberFormat nf = new DecimalFormat("00");
441    
442                    String yearStr = hasYear() ? getYear().toString() : null;
443                    String monthStr = hasMonth() ? nf.format(getMonth()) : null;
444                    String dateStr = hasDate() ? nf.format(getDate()) : null;
445    
446                    String dash = extended ? "-" : "";
447                    if (hasYear() && !hasMonth() && !hasDate()) {
448                            sb.append(yearStr);
449                    } else if (!hasYear() && hasMonth() && !hasDate()) {
450                            sb.append("--").append(monthStr);
451                    } else if (!hasYear() && !hasMonth() && hasDate()) {
452                            sb.append("---").append(dateStr);
453                    } else if (hasYear() && hasMonth() && !hasDate()) {
454                            sb.append(yearStr).append("-").append(monthStr);
455                    } else if (!hasYear() && hasMonth() && hasDate()) {
456                            sb.append("--").append(monthStr).append(dash).append(dateStr);
457                    } else if (hasYear() && !hasMonth() && hasDate()) {
458                            throw new IllegalStateException("Invalid date component combination: year, date");
459                    } else if (hasYear() && hasMonth() && hasDate()) {
460                            sb.append(yearStr).append(dash).append(monthStr).append(dash).append(dateStr);
461                    }
462    
463                    if (hasTimeComponent()) {
464                            sb.append('T');
465    
466                            String hourStr = hasHour() ? nf.format(getHour()) : null;
467                            String minuteStr = hasMinute() ? nf.format(getMinute()) : null;
468                            String secondStr = hasSecond() ? nf.format(getSecond()) : null;
469    
470                            dash = extended ? ":" : "";
471                            if (hasHour() && !hasMinute() && !hasSecond()) {
472                                    sb.append(hourStr);
473                            } else if (!hasHour() && hasMinute() && !hasSecond()) {
474                                    sb.append("-").append(minuteStr);
475                            } else if (!hasHour() && !hasMinute() && hasSecond()) {
476                                    sb.append("--").append(secondStr);
477                            } else if (hasHour() && hasMinute() && !hasSecond()) {
478                                    sb.append(hourStr).append(dash).append(minuteStr);
479                            } else if (!hasHour() && hasMinute() && hasSecond()) {
480                                    sb.append("-").append(minuteStr).append(dash).append(secondStr);
481                            } else if (hasHour() && !hasMinute() && hasSecond()) {
482                                    throw new IllegalStateException("Invalid time component combination: hour, second");
483                            } else if (hasHour() && hasMinute() && hasSecond()) {
484                                    sb.append(hourStr).append(dash).append(minuteStr).append(dash).append(secondStr);
485                            }
486    
487                            if (hasTimezone()) {
488                                    Integer[] timezone = getTimezone();
489                                    if (timezone[1] == null) {
490                                            timezone[1] = 0;
491                                    }
492                                    sb.append(new UtcOffset(timezone[0], timezone[1]).toString(extended));
493                            }
494                    }
495    
496                    return sb.toString();
497            }
498    
499            @Override
500            public int hashCode() {
501                    final int prime = 31;
502                    int result = 1;
503                    result = prime * result + Arrays.hashCode(components);
504                    return result;
505            }
506    
507            @Override
508            public boolean equals(Object obj) {
509                    if (this == obj)
510                            return true;
511                    if (obj == null)
512                            return false;
513                    if (getClass() != obj.getClass())
514                            return false;
515                    PartialDate other = (PartialDate) obj;
516                    if (!Arrays.equals(components, other.components))
517                            return false;
518                    return true;
519            }
520    
521            @Override
522            public String toString() {
523                    return toDateAndOrTime(true);
524            }
525    
526            /**
527             * Represents a string format that a partial date can be in.
528             */
529            private static class Format {
530                    private Pattern regex;
531                    private int[] componentIndexes;
532    
533                    /**
534                     * @param regex the regular expression that describes the format
535                     * @param componentIndexes the indexes of the
536                     * {@link PartialDate#components} array to assign the value of each
537                     * regex group to, or -1 to ignore the group
538                     */
539                    public Format(String regex, int... componentIndexes) {
540                            this.regex = Pattern.compile("^" + regex + "$");
541                            this.componentIndexes = componentIndexes;
542                    }
543    
544                    /**
545                     * Tries to parse a given string.
546                     * @param partialDate the {@link PartialDate} object
547                     * @param value the string
548                     * @return true if the string was successfully parsed, false if not
549                     */
550                    public boolean parse(PartialDate partialDate, String value) {
551                            Matcher m = regex.matcher(value);
552                            if (m.find()) {
553                                    for (int i = 0; i < componentIndexes.length; i++) {
554                                            int index = componentIndexes[i];
555                                            if (index == SKIP) {
556                                                    continue;
557                                            }
558    
559                                            int group = i + 1;
560                                            String groupStr = m.group(group);
561                                            if (groupStr != null) {
562                                                    if (groupStr.startsWith("+")) {
563                                                            groupStr = groupStr.substring(1);
564                                                    }
565                                                    partialDate.components[index] = Integer.valueOf(groupStr);
566                                            }
567                                    }
568                                    return true;
569                            }
570                            return false;
571                    }
572            }
573    }