View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    * 
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   * 
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.lang3.time;
18  
19  import java.util.ArrayList;
20  import java.util.Calendar;
21  import java.util.Date;
22  import java.util.GregorianCalendar;
23  import java.util.TimeZone;
24  
25  import org.apache.commons.lang3.StringUtils;
26  import org.apache.commons.lang3.Validate;
27  
28  /**
29   * <p>Duration formatting utilities and constants. The following table describes the tokens 
30   * used in the pattern language for formatting. </p>
31   * <table border="1" summary="Pattern Tokens">
32   *  <tr><th>character</th><th>duration element</th></tr>
33   *  <tr><td>y</td><td>years</td></tr>
34   *  <tr><td>M</td><td>months</td></tr>
35   *  <tr><td>d</td><td>days</td></tr>
36   *  <tr><td>H</td><td>hours</td></tr>
37   *  <tr><td>m</td><td>minutes</td></tr>
38   *  <tr><td>s</td><td>seconds</td></tr>
39   *  <tr><td>S</td><td>milliseconds</td></tr>
40   *  <tr><td>'text'</td><td>arbitrary text content</td></tr>
41   * </table>
42   *
43   * <b>Note: It's not currently possible to include a single-quote in a format.</b>
44   * <br>
45   * Token values are printed using decimal digits.
46   * A token character can be repeated to ensure that the field occupies a certain minimum
47   * size. Values will be left-padded with 0 unless padding is disabled in the method invocation.
48   * @since 2.1
49   */
50  public class DurationFormatUtils {
51  
52      /**
53       * <p>DurationFormatUtils instances should NOT be constructed in standard programming.</p>
54       *
55       * <p>This constructor is public to permit tools that require a JavaBean instance
56       * to operate.</p>
57       */
58      public DurationFormatUtils() {
59          super();
60      }
61  
62      /**
63       * <p>Pattern used with <code>FastDateFormat</code> and <code>SimpleDateFormat</code>
64       * for the ISO 8601 period format used in durations.</p>
65       * 
66       * @see org.apache.commons.lang3.time.FastDateFormat
67       * @see java.text.SimpleDateFormat
68       */
69      public static final String ISO_EXTENDED_FORMAT_PATTERN = "'P'yyyy'Y'M'M'd'DT'H'H'm'M's.SSS'S'";
70  
71      //-----------------------------------------------------------------------
72      /**
73       * <p>Formats the time gap as a string.</p>
74       * 
75       * <p>The format used is ISO 8601-like: {@code HH:mm:ss.SSS}.</p>
76       *
77       * @param durationMillis  the duration to format
78       * @return the formatted duration, not null
79       * @throws java.lang.IllegalArgumentException if durationMillis is negative
80       */
81      public static String formatDurationHMS(final long durationMillis) {
82          return formatDuration(durationMillis, "HH:mm:ss.SSS");
83      }
84  
85      /**
86       * <p>Formats the time gap as a string.</p>
87       * 
88       * <p>The format used is the ISO 8601 period format.</p>
89       * 
90       * <p>This method formats durations using the days and lower fields of the
91       * ISO format pattern, such as P7D6TH5M4.321S.</p>
92       * 
93       * @param durationMillis  the duration to format
94       * @return the formatted duration, not null
95       * @throws java.lang.IllegalArgumentException if durationMillis is negative
96       */
97      public static String formatDurationISO(final long durationMillis) {
98          return formatDuration(durationMillis, ISO_EXTENDED_FORMAT_PATTERN, false);
99      }
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 plurialization 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 replacable 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", "");
185             if (tmp.length() != duration.length()) {
186                 duration = tmp;
187                 tmp = StringUtils.replaceOnce(duration, " 0 hours", "");
188                 if (tmp.length() != duration.length()) {
189                     duration = tmp;
190                     tmp = StringUtils.replaceOnce(duration, " 0 minutes", "");
191                     duration = tmp;
192                     if (tmp.length() != duration.length()) {
193                         duration = StringUtils.replaceOnce(tmp, " 0 seconds", "");
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", "");
204             if (tmp.length() != duration.length()) {
205                 duration = tmp;
206                 tmp = StringUtils.replaceOnce(duration, " 0 minutes", "");
207                 if (tmp.length() != duration.length()) {
208                     duration = tmp;
209                     tmp = StringUtils.replaceOnce(duration, " 0 hours", "");
210                     if (tmp.length() != duration.length()) {
211                         duration = StringUtils.replaceOnce(tmp, " 0 days", "");
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-&gt;February = 1 month and then 
263      * calculating days forwards, and not the 1 month and 26 days gained by 
264      * choosing March -&gt; 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 }