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