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