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