001package ezvcard.util;
002
003import java.time.DateTimeException;
004import java.time.Instant;
005import java.time.LocalDate;
006import java.time.LocalDateTime;
007import java.time.LocalTime;
008import java.time.OffsetDateTime;
009import java.time.ZoneOffset;
010import java.time.format.DateTimeFormatter;
011import java.time.temporal.ChronoField;
012import java.time.temporal.Temporal;
013import java.time.temporal.TemporalAccessor;
014import java.util.Arrays;
015import java.util.Locale;
016import java.util.Objects;
017import java.util.concurrent.TimeUnit;
018import java.util.regex.Matcher;
019import java.util.regex.Pattern;
020
021import ezvcard.Messages;
022
023/*
024 Copyright (c) 2012-2026, Michael Angstadt
025 All rights reserved.
026
027 Redistribution and use in source and binary forms, with or without
028 modification, are permitted provided that the following conditions are met: 
029
030 1. Redistributions of source code must retain the above copyright notice, this
031 list of conditions and the following disclaimer. 
032 2. Redistributions in binary form must reproduce the above copyright notice,
033 this list of conditions and the following disclaimer in the documentation
034 and/or other materials provided with the distribution. 
035
036 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
037 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
038 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
039 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
040 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
041 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
042 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
043 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
044 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
045 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
046
047 The views and conclusions contained in the software and documentation are those
048 of the authors and should not be interpreted as representing official policies, 
049 either expressed or implied, of the FreeBSD Project.
050 */
051
052/**
053 * Parses and formats vCard timestamp values. These date formats are defined in
054 * the ISO8601 specification.
055 * @author Michael Angstadt
056 */
057public enum VCardDateFormat {
058        /**
059         * <p>
060         * Formats dates using "extended" format. In this format, dashes separate
061         * the date components, and colons separate the time components.
062         * </p>
063         * <p>
064         * Examples:
065         * </p>
066         * <ul>
067         * <li>2012-07-01 ({@link LocalDate})</li>
068         * <li>2012-07-01T14:21:10 ({@link LocalDateTime})</li>
069         * <li>2012-07-01T14:21:10-05:00 ({@link OffsetDateTime})</li>
070         * <li>2012-07-01T14:21:10Z ({@link Instant})</li>
071         * </ul>
072         */
073        EXTENDED {
074                @Override
075                String getPattern(TemporalAccessor temporal) {
076                        if (temporal instanceof ZoneOffset) {
077                                return "xxx";
078                        }
079                        if (temporal instanceof Instant) {
080                                return "yyyy-MM-dd'T'HH:mm:ssX";
081                        }
082                        if (hasOffset(temporal)) {
083                                return "yyyy-MM-dd'T'HH:mm:ssxxx";
084                        }
085                        if (hasTime(temporal)) {
086                                return "yyyy-MM-dd'T'HH:mm:ss";
087                        }
088                        return "yyyy-MM-dd";
089                }
090        },
091
092        /**
093         * <p>
094         * Formats dates using "basic" format. In this format, nothing separates the
095         * date and time components from each other.
096         * </p>
097         * <p>
098         * Examples:
099         * </p>
100         * <ul>
101         * <li>20120701 ({@link LocalDate})</li>
102         * <li>20120701T142110 ({@link LocalDateTime})</li>
103         * <li>20120701T142110-0500 ({@link OffsetDateTime})</li>
104         * <li>20120701T142110Z ({@link Instant})</li>
105         * </ul>
106         */
107        BASIC {
108                @Override
109                String getPattern(TemporalAccessor temporal) {
110                        if (temporal instanceof ZoneOffset) {
111                                return "xx";
112                        }
113                        if (temporal instanceof Instant) {
114                                return "yyyyMMdd'T'HHmmssX";
115                        }
116                        if (hasOffset(temporal)) {
117                                return "yyyyMMdd'T'HHmmssxx";
118                        }
119                        if (hasTime(temporal)) {
120                                return "yyyyMMdd'T'HHmmss";
121                        }
122                        return "yyyyMMdd";
123                }
124        };
125
126        /**
127         * Formats a date (also accepts {@link ZoneOffset}).
128         * @param temporalAccessor the date
129         * @return the formatted date
130         */
131        public String format(TemporalAccessor temporalAccessor) {
132                String pattern = getPattern(temporalAccessor);
133                DateTimeFormatter df = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);
134
135                /*
136                 * Instants must be converted to OffsetDateTime in order to be formatted
137                 * using a format pattern.
138                 */
139                if (temporalAccessor instanceof Instant) {
140                        temporalAccessor = ((Instant) temporalAccessor).atOffset(ZoneOffset.UTC);
141                }
142
143                return df.format(temporalAccessor);
144        }
145
146        abstract String getPattern(TemporalAccessor temporal);
147
148        /**
149         * <p>
150         * Parses a date string. String can be in basic or extended formats.
151         * </p>
152         * <p>
153         * Examples:
154         * </p>
155         * <ul>
156         * <li>"2012-07-01" returns {@link LocalDate}</li>
157         * <li>"2012-07-01T14:21:10" returns {@link LocalDateTime}</li>
158         * <li>"2012-07-01T14:21:10-05:00" returns {@link OffsetDateTime}</li>
159         * <li>"2012-07-01T14:21:10Z" returns {@link Instant}</li>
160         * </ul>
161         * @param string the string to parse
162         * @return the parsed date
163         * @throws IllegalArgumentException if the date string isn't in one of the
164         * accepted ISO8601 formats or if it contains an invalid value (e.g. "13"
165         * for the month)
166         */
167        public static Temporal parse(String string) {
168                TimestampPattern p = TimestampPattern.parse(string);
169                if (p == null) {
170                        throw Messages.INSTANCE.getIllegalArgumentException(41, string);
171                }
172
173                try {
174                        LocalDate date = LocalDate.of(p.year(), p.month(), p.date());
175                        if (!p.hasTime()) {
176                                return date;
177                        }
178
179                        LocalTime time = LocalTime.of(p.hour(), p.minute(), p.second(), p.nanosecond());
180                        LocalDateTime datetime = LocalDateTime.of(date, time);
181
182                        ZoneOffset offset = p.offset();
183                        if (offset == null) {
184                                return datetime;
185                        }
186
187                        OffsetDateTime offsetDateTime = OffsetDateTime.of(datetime, offset);
188                        return "Z".equals(offset.getId()) ? Instant.from(offsetDateTime) : offsetDateTime;
189                } catch (DateTimeException e) {
190                        throw new IllegalArgumentException(e);
191                }
192        }
193
194        /**
195         * Wrapper for a complex regular expression that parses multiple date
196         * formats.
197         */
198        private static class TimestampPattern {
199                //@formatter:off
200                private static final Pattern regex = Pattern.compile(
201                        "^(\\d{4})(" +
202                                "-?(\\d{2})-?(\\d{2})|" +
203                                "-(\\d{1,2})-(\\d{1,2})" + //allow single digit month and/or date as long as there are dashes
204                        ")" + 
205                        "(" +
206                                "T(\\d{2}):?(\\d{2}):?(\\d{2})(\\.\\d+)?" +
207                                "(" +
208                                        "Z|([-+])((\\d{2})|((\\d{2}):?(\\d{2})))" +
209                                ")?" +
210                        ")?$"
211                );
212                //@formatter:on
213
214                private final Matcher matcher;
215
216                private TimestampPattern(Matcher matcher) {
217                        this.matcher = matcher;
218                }
219
220                /**
221                 * Attempts to match the given string against the timestamp regex.
222                 * @param string the string to parse
223                 * @return the matched pattern or null if the string did not match the
224                 * pattern
225                 */
226                public static TimestampPattern parse(String string) {
227                        Matcher m = regex.matcher(string);
228                        return m.find() ? new TimestampPattern(m) : null;
229                }
230
231                public int year() {
232                        return parseInt(1);
233                }
234
235                public int month() {
236                        return parseInt(3, 5);
237                }
238
239                public int date() {
240                        return parseInt(4, 6);
241                }
242
243                public boolean hasTime() {
244                        return matcher.group(8) != null;
245                }
246
247                public int hour() {
248                        return parseInt(8);
249                }
250
251                public int minute() {
252                        return parseInt(9);
253                }
254
255                public int second() {
256                        return parseInt(10);
257                }
258
259                public int nanosecond() {
260                        String s = matcher.group(11);
261                        if (s == null) {
262                                return 0;
263                        }
264
265                        double nanos = Double.parseDouble(s) * TimeUnit.SECONDS.toNanos(1);
266                        return (int) Math.round(nanos);
267                }
268
269                public ZoneOffset offset() {
270                        String offsetStr = matcher.group(12);
271                        return (offsetStr == null) ? null : ZoneOffset.of(offsetStr);
272                }
273
274                private int parseInt(int... group) {
275                        //@formatter:off
276                        return Arrays.stream(group)
277                                .mapToObj(matcher::group)
278                                .filter(Objects::nonNull)
279                                .mapToInt(Integer::parseInt)
280                        .findFirst().orElseThrow(NullPointerException::new);
281                        //@formatter:on
282                }
283        }
284
285        /**
286         * Determines if the given date has a time component
287         * @param temporalAccessor the date
288         * @return true if it has a time component, false if not
289         */
290        public static boolean hasTime(TemporalAccessor temporalAccessor) {
291                return temporalAccessor instanceof Instant || temporalAccessor.isSupported(ChronoField.HOUR_OF_DAY);
292        }
293
294        private static boolean hasOffset(TemporalAccessor temporalAccessor) {
295                return temporalAccessor.isSupported(ChronoField.OFFSET_SECONDS);
296        }
297}