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