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