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