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