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