001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.commons.lang3.time; 018 019import java.util.ArrayList; 020import java.util.Calendar; 021import java.util.Date; 022import java.util.GregorianCalendar; 023import java.util.TimeZone; 024 025import org.apache.commons.lang3.StringUtils; 026import org.apache.commons.lang3.Validate; 027 028/** 029 * <p>Duration formatting utilities and constants. The following table describes the tokens 030 * used in the pattern language for formatting. </p> 031 * <table border="1" summary="Pattern Tokens"> 032 * <tr><th>character</th><th>duration element</th></tr> 033 * <tr><td>y</td><td>years</td></tr> 034 * <tr><td>M</td><td>months</td></tr> 035 * <tr><td>d</td><td>days</td></tr> 036 * <tr><td>H</td><td>hours</td></tr> 037 * <tr><td>m</td><td>minutes</td></tr> 038 * <tr><td>s</td><td>seconds</td></tr> 039 * <tr><td>S</td><td>milliseconds</td></tr> 040 * <tr><td>'text'</td><td>arbitrary text content</td></tr> 041 * </table> 042 * 043 * <b>Note: It's not currently possible to include a single-quote in a format.</b> 044 * <br> 045 * Token values are printed using decimal digits. 046 * A token character can be repeated to ensure that the field occupies a certain minimum 047 * size. Values will be left-padded with 0 unless padding is disabled in the method invocation. 048 * @since 2.1 049 */ 050public class DurationFormatUtils { 051 052 /** 053 * <p>DurationFormatUtils instances should NOT be constructed in standard programming.</p> 054 * 055 * <p>This constructor is public to permit tools that require a JavaBean instance 056 * to operate.</p> 057 */ 058 public DurationFormatUtils() { 059 super(); 060 } 061 062 /** 063 * <p>Pattern used with <code>FastDateFormat</code> and <code>SimpleDateFormat</code> 064 * for the ISO 8601 period format used in durations.</p> 065 * 066 * @see org.apache.commons.lang3.time.FastDateFormat 067 * @see java.text.SimpleDateFormat 068 */ 069 public static final String ISO_EXTENDED_FORMAT_PATTERN = "'P'yyyy'Y'M'M'd'DT'H'H'm'M's.SSS'S'"; 070 071 //----------------------------------------------------------------------- 072 /** 073 * <p>Formats the time gap as a string.</p> 074 * 075 * <p>The format used is ISO 8601-like: {@code HH:mm:ss.SSS}.</p> 076 * 077 * @param durationMillis the duration to format 078 * @return the formatted duration, not null 079 * @throws java.lang.IllegalArgumentException if durationMillis is negative 080 */ 081 public static String formatDurationHMS(final long durationMillis) { 082 return formatDuration(durationMillis, "HH:mm:ss.SSS"); 083 } 084 085 /** 086 * <p>Formats the time gap as a string.</p> 087 * 088 * <p>The format used is the ISO 8601 period format.</p> 089 * 090 * <p>This method formats durations using the days and lower fields of the 091 * ISO format pattern, such as P7D6TH5M4.321S.</p> 092 * 093 * @param durationMillis the duration to format 094 * @return the formatted duration, not null 095 * @throws java.lang.IllegalArgumentException if durationMillis is negative 096 */ 097 public static String formatDurationISO(final long durationMillis) { 098 return formatDuration(durationMillis, ISO_EXTENDED_FORMAT_PATTERN, false); 099 } 100 101 /** 102 * <p>Formats the time gap as a string, using the specified format, and padding with zeros.</p> 103 * 104 * <p>This method formats durations using the days and lower fields of the 105 * format pattern. Months and larger are not used.</p> 106 * 107 * @param durationMillis the duration to format 108 * @param format the way in which to format the duration, not null 109 * @return the formatted duration, not null 110 * @throws java.lang.IllegalArgumentException if durationMillis is negative 111 */ 112 public static String formatDuration(final long durationMillis, final String format) { 113 return formatDuration(durationMillis, format, true); 114 } 115 116 /** 117 * <p>Formats the time gap as a string, using the specified format. 118 * Padding the left hand side of numbers with zeroes is optional.</p> 119 * 120 * <p>This method formats durations using the days and lower fields of the 121 * format pattern. Months and larger are not used.</p> 122 * 123 * @param durationMillis the duration to format 124 * @param format the way in which to format the duration, not null 125 * @param padWithZeros whether to pad the left hand side of numbers with 0's 126 * @return the formatted duration, not null 127 * @throws java.lang.IllegalArgumentException if durationMillis is negative 128 */ 129 public static String formatDuration(final long durationMillis, final String format, final boolean padWithZeros) { 130 Validate.inclusiveBetween(0, Long.MAX_VALUE, durationMillis, "durationMillis must not be negative"); 131 132 final Token[] tokens = lexx(format); 133 134 long days = 0; 135 long hours = 0; 136 long minutes = 0; 137 long seconds = 0; 138 long milliseconds = durationMillis; 139 140 if (Token.containsTokenWithValue(tokens, d) ) { 141 days = milliseconds / DateUtils.MILLIS_PER_DAY; 142 milliseconds = milliseconds - (days * DateUtils.MILLIS_PER_DAY); 143 } 144 if (Token.containsTokenWithValue(tokens, H) ) { 145 hours = milliseconds / DateUtils.MILLIS_PER_HOUR; 146 milliseconds = milliseconds - (hours * DateUtils.MILLIS_PER_HOUR); 147 } 148 if (Token.containsTokenWithValue(tokens, m) ) { 149 minutes = milliseconds / DateUtils.MILLIS_PER_MINUTE; 150 milliseconds = milliseconds - (minutes * DateUtils.MILLIS_PER_MINUTE); 151 } 152 if (Token.containsTokenWithValue(tokens, s) ) { 153 seconds = milliseconds / DateUtils.MILLIS_PER_SECOND; 154 milliseconds = milliseconds - (seconds * DateUtils.MILLIS_PER_SECOND); 155 } 156 157 return format(tokens, 0, 0, days, hours, minutes, seconds, milliseconds, padWithZeros); 158 } 159 160 /** 161 * <p>Formats an elapsed time into a pluralization correct string.</p> 162 * 163 * <p>This method formats durations using the days and lower fields of the 164 * format pattern. Months and larger are not used.</p> 165 * 166 * @param durationMillis the elapsed time to report in milliseconds 167 * @param suppressLeadingZeroElements suppresses leading 0 elements 168 * @param suppressTrailingZeroElements suppresses trailing 0 elements 169 * @return the formatted text in days/hours/minutes/seconds, not null 170 * @throws java.lang.IllegalArgumentException if durationMillis is negative 171 */ 172 public static String formatDurationWords( 173 final long durationMillis, 174 final boolean suppressLeadingZeroElements, 175 final boolean suppressTrailingZeroElements) { 176 177 // This method is generally replaceable by the format method, but 178 // there are a series of tweaks and special cases that require 179 // trickery to replicate. 180 String duration = formatDuration(durationMillis, "d' days 'H' hours 'm' minutes 's' seconds'"); 181 if (suppressLeadingZeroElements) { 182 // this is a temporary marker on the front. Like ^ in regexp. 183 duration = " " + duration; 184 String tmp = StringUtils.replaceOnce(duration, " 0 days", StringUtils.EMPTY); 185 if (tmp.length() != duration.length()) { 186 duration = tmp; 187 tmp = StringUtils.replaceOnce(duration, " 0 hours", StringUtils.EMPTY); 188 if (tmp.length() != duration.length()) { 189 duration = tmp; 190 tmp = StringUtils.replaceOnce(duration, " 0 minutes", StringUtils.EMPTY); 191 duration = tmp; 192 if (tmp.length() != duration.length()) { 193 duration = StringUtils.replaceOnce(tmp, " 0 seconds", StringUtils.EMPTY); 194 } 195 } 196 } 197 if (duration.length() != 0) { 198 // strip the space off again 199 duration = duration.substring(1); 200 } 201 } 202 if (suppressTrailingZeroElements) { 203 String tmp = StringUtils.replaceOnce(duration, " 0 seconds", StringUtils.EMPTY); 204 if (tmp.length() != duration.length()) { 205 duration = tmp; 206 tmp = StringUtils.replaceOnce(duration, " 0 minutes", StringUtils.EMPTY); 207 if (tmp.length() != duration.length()) { 208 duration = tmp; 209 tmp = StringUtils.replaceOnce(duration, " 0 hours", StringUtils.EMPTY); 210 if (tmp.length() != duration.length()) { 211 duration = StringUtils.replaceOnce(tmp, " 0 days", StringUtils.EMPTY); 212 } 213 } 214 } 215 } 216 // handle plurals 217 duration = " " + duration; 218 duration = StringUtils.replaceOnce(duration, " 1 seconds", " 1 second"); 219 duration = StringUtils.replaceOnce(duration, " 1 minutes", " 1 minute"); 220 duration = StringUtils.replaceOnce(duration, " 1 hours", " 1 hour"); 221 duration = StringUtils.replaceOnce(duration, " 1 days", " 1 day"); 222 return duration.trim(); 223 } 224 225 //----------------------------------------------------------------------- 226 /** 227 * <p>Formats the time gap as a string.</p> 228 * 229 * <p>The format used is the ISO 8601 period format.</p> 230 * 231 * @param startMillis the start of the duration to format 232 * @param endMillis the end of the duration to format 233 * @return the formatted duration, not null 234 * @throws java.lang.IllegalArgumentException if startMillis is greater than endMillis 235 */ 236 public static String formatPeriodISO(final long startMillis, final long endMillis) { 237 return formatPeriod(startMillis, endMillis, ISO_EXTENDED_FORMAT_PATTERN, false, TimeZone.getDefault()); 238 } 239 240 /** 241 * <p>Formats the time gap as a string, using the specified format. 242 * Padding the left hand side of numbers with zeroes is optional. 243 * 244 * @param startMillis the start of the duration 245 * @param endMillis the end of the duration 246 * @param format the way in which to format the duration, not null 247 * @return the formatted duration, not null 248 * @throws java.lang.IllegalArgumentException if startMillis is greater than endMillis 249 */ 250 public static String formatPeriod(final long startMillis, final long endMillis, final String format) { 251 return formatPeriod(startMillis, endMillis, format, true, TimeZone.getDefault()); 252 } 253 254 /** 255 * <p>Formats the time gap as a string, using the specified format. 256 * Padding the left hand side of numbers with zeroes is optional and 257 * the timezone may be specified. </p> 258 * 259 * <p>When calculating the difference between months/days, it chooses to 260 * calculate months first. So when working out the number of months and 261 * days between January 15th and March 10th, it choose 1 month and 262 * 23 days gained by choosing January->February = 1 month and then 263 * calculating days forwards, and not the 1 month and 26 days gained by 264 * choosing March -> February = 1 month and then calculating days 265 * backwards. </p> 266 * 267 * <p>For more control, the <a href="http://joda-time.sf.net/">Joda-Time</a> 268 * library is recommended.</p> 269 * 270 * @param startMillis the start of the duration 271 * @param endMillis the end of the duration 272 * @param format the way in which to format the duration, not null 273 * @param padWithZeros whether to pad the left hand side of numbers with 0's 274 * @param timezone the millis are defined in 275 * @return the formatted duration, not null 276 * @throws java.lang.IllegalArgumentException if startMillis is greater than endMillis 277 */ 278 public static String formatPeriod(final long startMillis, final long endMillis, final String format, final boolean padWithZeros, 279 final TimeZone timezone) { 280 Validate.isTrue(startMillis <= endMillis, "startMillis must not be greater than endMillis"); 281 282 283 // Used to optimise for differences under 28 days and 284 // called formatDuration(millis, format); however this did not work 285 // over leap years. 286 // TODO: Compare performance to see if anything was lost by 287 // losing this optimisation. 288 289 final Token[] tokens = lexx(format); 290 291 // timezones get funky around 0, so normalizing everything to GMT 292 // stops the hours being off 293 final Calendar start = Calendar.getInstance(timezone); 294 start.setTime(new Date(startMillis)); 295 final Calendar end = Calendar.getInstance(timezone); 296 end.setTime(new Date(endMillis)); 297 298 // initial estimates 299 int milliseconds = end.get(Calendar.MILLISECOND) - start.get(Calendar.MILLISECOND); 300 int seconds = end.get(Calendar.SECOND) - start.get(Calendar.SECOND); 301 int minutes = end.get(Calendar.MINUTE) - start.get(Calendar.MINUTE); 302 int hours = end.get(Calendar.HOUR_OF_DAY) - start.get(Calendar.HOUR_OF_DAY); 303 int days = end.get(Calendar.DAY_OF_MONTH) - start.get(Calendar.DAY_OF_MONTH); 304 int months = end.get(Calendar.MONTH) - start.get(Calendar.MONTH); 305 int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); 306 307 // each initial estimate is adjusted in case it is under 0 308 while (milliseconds < 0) { 309 milliseconds += 1000; 310 seconds -= 1; 311 } 312 while (seconds < 0) { 313 seconds += 60; 314 minutes -= 1; 315 } 316 while (minutes < 0) { 317 minutes += 60; 318 hours -= 1; 319 } 320 while (hours < 0) { 321 hours += 24; 322 days -= 1; 323 } 324 325 if (Token.containsTokenWithValue(tokens, M)) { 326 while (days < 0) { 327 days += start.getActualMaximum(Calendar.DAY_OF_MONTH); 328 months -= 1; 329 start.add(Calendar.MONTH, 1); 330 } 331 332 while (months < 0) { 333 months += 12; 334 years -= 1; 335 } 336 337 if (!Token.containsTokenWithValue(tokens, y) && years != 0) { 338 while (years != 0) { 339 months += 12 * years; 340 years = 0; 341 } 342 } 343 } else { 344 // there are no M's in the format string 345 346 if( !Token.containsTokenWithValue(tokens, y) ) { 347 int target = end.get(Calendar.YEAR); 348 if (months < 0) { 349 // target is end-year -1 350 target -= 1; 351 } 352 353 while (start.get(Calendar.YEAR) != target) { 354 days += start.getActualMaximum(Calendar.DAY_OF_YEAR) - start.get(Calendar.DAY_OF_YEAR); 355 356 // Not sure I grok why this is needed, but the brutal tests show it is 357 if (start instanceof GregorianCalendar && 358 start.get(Calendar.MONTH) == Calendar.FEBRUARY && 359 start.get(Calendar.DAY_OF_MONTH) == 29) { 360 days += 1; 361 } 362 363 start.add(Calendar.YEAR, 1); 364 365 days += start.get(Calendar.DAY_OF_YEAR); 366 } 367 368 years = 0; 369 } 370 371 while( start.get(Calendar.MONTH) != end.get(Calendar.MONTH) ) { 372 days += start.getActualMaximum(Calendar.DAY_OF_MONTH); 373 start.add(Calendar.MONTH, 1); 374 } 375 376 months = 0; 377 378 while (days < 0) { 379 days += start.getActualMaximum(Calendar.DAY_OF_MONTH); 380 months -= 1; 381 start.add(Calendar.MONTH, 1); 382 } 383 384 } 385 386 // The rest of this code adds in values that 387 // aren't requested. This allows the user to ask for the 388 // number of months and get the real count and not just 0->11. 389 390 if (!Token.containsTokenWithValue(tokens, d)) { 391 hours += 24 * days; 392 days = 0; 393 } 394 if (!Token.containsTokenWithValue(tokens, H)) { 395 minutes += 60 * hours; 396 hours = 0; 397 } 398 if (!Token.containsTokenWithValue(tokens, m)) { 399 seconds += 60 * minutes; 400 minutes = 0; 401 } 402 if (!Token.containsTokenWithValue(tokens, s)) { 403 milliseconds += 1000 * seconds; 404 seconds = 0; 405 } 406 407 return format(tokens, years, months, days, hours, minutes, seconds, milliseconds, padWithZeros); 408 } 409 410 //----------------------------------------------------------------------- 411 /** 412 * <p>The internal method to do the formatting.</p> 413 * 414 * @param tokens the tokens 415 * @param years the number of years 416 * @param months the number of months 417 * @param days the number of days 418 * @param hours the number of hours 419 * @param minutes the number of minutes 420 * @param seconds the number of seconds 421 * @param milliseconds the number of millis 422 * @param padWithZeros whether to pad 423 * @return the formatted string 424 */ 425 static String format(final Token[] tokens, final long years, final long months, final long days, final long hours, final long minutes, final long seconds, 426 final long milliseconds, final boolean padWithZeros) { 427 final StringBuilder buffer = new StringBuilder(); 428 boolean lastOutputSeconds = false; 429 for (final Token token : tokens) { 430 final Object value = token.getValue(); 431 final int count = token.getCount(); 432 if (value instanceof StringBuilder) { 433 buffer.append(value.toString()); 434 } else { 435 if (value.equals(y)) { 436 buffer.append(paddedValue(years, padWithZeros, count)); 437 lastOutputSeconds = false; 438 } else if (value.equals(M)) { 439 buffer.append(paddedValue(months, padWithZeros, count)); 440 lastOutputSeconds = false; 441 } else if (value.equals(d)) { 442 buffer.append(paddedValue(days, padWithZeros, count)); 443 lastOutputSeconds = false; 444 } else if (value.equals(H)) { 445 buffer.append(paddedValue(hours, padWithZeros, count)); 446 lastOutputSeconds = false; 447 } else if (value.equals(m)) { 448 buffer.append(paddedValue(minutes, padWithZeros, count)); 449 lastOutputSeconds = false; 450 } else if (value.equals(s)) { 451 buffer.append(paddedValue(seconds, padWithZeros, count)); 452 lastOutputSeconds = true; 453 } else if (value.equals(S)) { 454 if (lastOutputSeconds) { 455 // ensure at least 3 digits are displayed even if padding is not selected 456 final int width = padWithZeros ? Math.max(3, count) : 3; 457 buffer.append(paddedValue(milliseconds, true, width)); 458 } else { 459 buffer.append(paddedValue(milliseconds, padWithZeros, count)); 460 } 461 lastOutputSeconds = false; 462 } 463 } 464 } 465 return buffer.toString(); 466 } 467 468 /** 469 * <p>Converts a {@code long} to a {@code String} with optional 470 * zero padding.</p> 471 * 472 * @param value the value to convert 473 * @param padWithZeros whether to pad with zeroes 474 * @param count the size to pad to (ignored if {@code padWithZeros} is false) 475 * @return the string result 476 */ 477 private static String paddedValue(final long value, final boolean padWithZeros, final int count) { 478 final String longString = Long.toString(value); 479 return padWithZeros ? StringUtils.leftPad(longString, count, '0') : longString; 480 } 481 482 static final Object y = "y"; 483 static final Object M = "M"; 484 static final Object d = "d"; 485 static final Object H = "H"; 486 static final Object m = "m"; 487 static final Object s = "s"; 488 static final Object S = "S"; 489 490 /** 491 * Parses a classic date format string into Tokens 492 * 493 * @param format the format to parse, not null 494 * @return array of Token[] 495 */ 496 static Token[] lexx(final String format) { 497 final ArrayList<Token> list = new ArrayList<Token>(format.length()); 498 499 boolean inLiteral = false; 500 // Although the buffer is stored in a Token, the Tokens are only 501 // used internally, so cannot be accessed by other threads 502 StringBuilder buffer = null; 503 Token previous = null; 504 for (int i = 0; i < format.length(); i++) { 505 final char ch = format.charAt(i); 506 if (inLiteral && ch != '\'') { 507 buffer.append(ch); // buffer can't be null if inLiteral is true 508 continue; 509 } 510 Object value = null; 511 switch (ch) { 512 // TODO: Need to handle escaping of ' 513 case '\'': 514 if (inLiteral) { 515 buffer = null; 516 inLiteral = false; 517 } else { 518 buffer = new StringBuilder(); 519 list.add(new Token(buffer)); 520 inLiteral = true; 521 } 522 break; 523 case 'y': 524 value = y; 525 break; 526 case 'M': 527 value = M; 528 break; 529 case 'd': 530 value = d; 531 break; 532 case 'H': 533 value = H; 534 break; 535 case 'm': 536 value = m; 537 break; 538 case 's': 539 value = s; 540 break; 541 case 'S': 542 value = S; 543 break; 544 default: 545 if (buffer == null) { 546 buffer = new StringBuilder(); 547 list.add(new Token(buffer)); 548 } 549 buffer.append(ch); 550 } 551 552 if (value != null) { 553 if (previous != null && previous.getValue().equals(value)) { 554 previous.increment(); 555 } else { 556 final Token token = new Token(value); 557 list.add(token); 558 previous = token; 559 } 560 buffer = null; 561 } 562 } 563 if (inLiteral) { // i.e. we have not found the end of the literal 564 throw new IllegalArgumentException("Unmatched quote in format: " + format); 565 } 566 return list.toArray(new Token[list.size()]); 567 } 568 569 //----------------------------------------------------------------------- 570 /** 571 * Element that is parsed from the format pattern. 572 */ 573 static class Token { 574 575 /** 576 * Helper method to determine if a set of tokens contain a value 577 * 578 * @param tokens set to look in 579 * @param value to look for 580 * @return boolean <code>true</code> if contained 581 */ 582 static boolean containsTokenWithValue(final Token[] tokens, final Object value) { 583 for (final Token token : tokens) { 584 if (token.getValue() == value) { 585 return true; 586 } 587 } 588 return false; 589 } 590 591 private final Object value; 592 private int count; 593 594 /** 595 * Wraps a token around a value. A value would be something like a 'Y'. 596 * 597 * @param value to wrap 598 */ 599 Token(final Object value) { 600 this.value = value; 601 this.count = 1; 602 } 603 604 /** 605 * Wraps a token around a repeated number of a value, for example it would 606 * store 'yyyy' as a value for y and a count of 4. 607 * 608 * @param value to wrap 609 * @param count to wrap 610 */ 611 Token(final Object value, final int count) { 612 this.value = value; 613 this.count = count; 614 } 615 616 /** 617 * Adds another one of the value 618 */ 619 void increment() { 620 count++; 621 } 622 623 /** 624 * Gets the current number of values represented 625 * 626 * @return int number of values represented 627 */ 628 int getCount() { 629 return count; 630 } 631 632 /** 633 * Gets the particular value this token represents. 634 * 635 * @return Object value 636 */ 637 Object getValue() { 638 return value; 639 } 640 641 /** 642 * Supports equality of this Token to another Token. 643 * 644 * @param obj2 Object to consider equality of 645 * @return boolean <code>true</code> if equal 646 */ 647 @Override 648 public boolean equals(final Object obj2) { 649 if (obj2 instanceof Token) { 650 final Token tok2 = (Token) obj2; 651 if (this.value.getClass() != tok2.value.getClass()) { 652 return false; 653 } 654 if (this.count != tok2.count) { 655 return false; 656 } 657 if (this.value instanceof StringBuilder) { 658 return this.value.toString().equals(tok2.value.toString()); 659 } else if (this.value instanceof Number) { 660 return this.value.equals(tok2.value); 661 } else { 662 return this.value == tok2.value; 663 } 664 } 665 return false; 666 } 667 668 /** 669 * Returns a hash code for the token equal to the 670 * hash code for the token's value. Thus 'TT' and 'TTTT' 671 * will have the same hash code. 672 * 673 * @return The hash code for the token 674 */ 675 @Override 676 public int hashCode() { 677 return this.value.hashCode(); 678 } 679 680 /** 681 * Represents this token as a String. 682 * 683 * @return String representation of the token 684 */ 685 @Override 686 public String toString() { 687 return StringUtils.repeat(this.value.toString(), this.count); 688 } 689 } 690 691}