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 }