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}