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