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}