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