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  
27  /**
28   * <p>Duration formatting utilities and constants. The following table describes the tokens 
29   * used in the pattern language for formatting. </p>
30   * <table border="1">
31   *  <tr><th>character</th><th>duration element</th></tr>
32   *  <tr><td>y</td><td>years</td></tr>
33   *  <tr><td>M</td><td>months</td></tr>
34   *  <tr><td>d</td><td>days</td></tr>
35   *  <tr><td>H</td><td>hours</td></tr>
36   *  <tr><td>m</td><td>minutes</td></tr>
37   *  <tr><td>s</td><td>seconds</td></tr>
38   *  <tr><td>S</td><td>milliseconds</td></tr>
39   * </table>
40   *
41   * @since 2.1
42   * @version $Id: DurationFormatUtils.java 1436770 2013-01-22 07:09:45Z ggregory $
43   */
44  public class DurationFormatUtils {
45  
46      /**
47       * <p>DurationFormatUtils instances should NOT be constructed in standard programming.</p>
48       *
49       * <p>This constructor is public to permit tools that require a JavaBean instance
50       * to operate.</p>
51       */
52      public DurationFormatUtils() {
53          super();
54      }
55  
56      /**
57       * <p>Pattern used with <code>FastDateFormat</code> and <code>SimpleDateFormat</code>
58       * for the ISO8601 period format used in durations.</p>
59       * 
60       * @see org.apache.commons.lang3.time.FastDateFormat
61       * @see java.text.SimpleDateFormat
62       */
63      public static final String ISO_EXTENDED_FORMAT_PATTERN = "'P'yyyy'Y'M'M'd'DT'H'H'm'M's.S'S'";
64  
65      //-----------------------------------------------------------------------
66      /**
67       * <p>Formats the time gap as a string.</p>
68       * 
69       * <p>The format used is ISO8601-like:
70       * <i>H</i>:<i>m</i>:<i>s</i>.<i>S</i>.</p>
71       * 
72       * @param durationMillis  the duration to format
73       * @return the formatted duration, not null
74       */
75      public static String formatDurationHMS(final long durationMillis) {
76          return formatDuration(durationMillis, "H:mm:ss.SSS");
77      }
78  
79      /**
80       * <p>Formats the time gap as a string.</p>
81       * 
82       * <p>The format used is the ISO8601 period format.</p>
83       * 
84       * <p>This method formats durations using the days and lower fields of the
85       * ISO format pattern, such as P7D6TH5M4.321S.</p>
86       * 
87       * @param durationMillis  the duration to format
88       * @return the formatted duration, not null
89       */
90      public static String formatDurationISO(final long durationMillis) {
91          return formatDuration(durationMillis, ISO_EXTENDED_FORMAT_PATTERN, false);
92      }
93  
94      /**
95       * <p>Formats the time gap as a string, using the specified format, and padding with zeros and 
96       * using the default timezone.</p>
97       * 
98       * <p>This method formats durations using the days and lower fields of the
99       * format pattern. Months and larger are not used.</p>
100      * 
101      * @param durationMillis  the duration to format
102      * @param format  the way in which to format the duration, not null
103      * @return the formatted duration, not null
104      */
105     public static String formatDuration(final long durationMillis, final String format) {
106         return formatDuration(durationMillis, format, true);
107     }
108 
109     /**
110      * <p>Formats the time gap as a string, using the specified format.
111      * Padding the left hand side of numbers with zeroes is optional and 
112      * the timezone may be specified.</p>
113      * 
114      * <p>This method formats durations using the days and lower fields of the
115      * format pattern. Months and larger are not used.</p>
116      * 
117      * @param durationMillis  the duration to format
118      * @param format  the way in which to format the duration, not null
119      * @param padWithZeros  whether to pad the left hand side of numbers with 0's
120      * @return the formatted duration, not null
121      */
122     public static String formatDuration(long durationMillis, final String format, final boolean padWithZeros) {
123 
124         final Token[] tokens = lexx(format);
125 
126         int days         = 0;
127         int hours        = 0;
128         int minutes      = 0;
129         int seconds      = 0;
130         int milliseconds = 0;
131         
132         if (Token.containsTokenWithValue(tokens, d) ) {
133             days = (int) (durationMillis / DateUtils.MILLIS_PER_DAY);
134             durationMillis = durationMillis - (days * DateUtils.MILLIS_PER_DAY);
135         }
136         if (Token.containsTokenWithValue(tokens, H) ) {
137             hours = (int) (durationMillis / DateUtils.MILLIS_PER_HOUR);
138             durationMillis = durationMillis - (hours * DateUtils.MILLIS_PER_HOUR);
139         }
140         if (Token.containsTokenWithValue(tokens, m) ) {
141             minutes = (int) (durationMillis / DateUtils.MILLIS_PER_MINUTE);
142             durationMillis = durationMillis - (minutes * DateUtils.MILLIS_PER_MINUTE);
143         }
144         if (Token.containsTokenWithValue(tokens, s) ) {
145             seconds = (int) (durationMillis / DateUtils.MILLIS_PER_SECOND);
146             durationMillis = durationMillis - (seconds * DateUtils.MILLIS_PER_SECOND);
147         }
148         if (Token.containsTokenWithValue(tokens, S) ) {
149             milliseconds = (int) durationMillis;
150         }
151 
152         return format(tokens, 0, 0, days, hours, minutes, seconds, milliseconds, padWithZeros);
153     }
154 
155     /**
156      * <p>Formats an elapsed time into a plurialization correct string.</p>
157      * 
158      * <p>This method formats durations using the days and lower fields of the
159      * format pattern. Months and larger are not used.</p>
160      * 
161      * @param durationMillis  the elapsed time to report in milliseconds
162      * @param suppressLeadingZeroElements  suppresses leading 0 elements
163      * @param suppressTrailingZeroElements  suppresses trailing 0 elements
164      * @return the formatted text in days/hours/minutes/seconds, not null
165      */
166     public static String formatDurationWords(
167         final long durationMillis,
168         final boolean suppressLeadingZeroElements,
169         final boolean suppressTrailingZeroElements) {
170 
171         // This method is generally replacable by the format method, but 
172         // there are a series of tweaks and special cases that require 
173         // trickery to replicate.
174         String duration = formatDuration(durationMillis, "d' days 'H' hours 'm' minutes 's' seconds'");
175         if (suppressLeadingZeroElements) {
176             // this is a temporary marker on the front. Like ^ in regexp.
177             duration = " " + duration;
178             String tmp = StringUtils.replaceOnce(duration, " 0 days", "");
179             if (tmp.length() != duration.length()) {
180                 duration = tmp;
181                 tmp = StringUtils.replaceOnce(duration, " 0 hours", "");
182                 if (tmp.length() != duration.length()) {
183                     duration = tmp;
184                     tmp = StringUtils.replaceOnce(duration, " 0 minutes", "");
185                     duration = tmp;
186                     if (tmp.length() != duration.length()) {
187                         duration = StringUtils.replaceOnce(tmp, " 0 seconds", "");
188                     }
189                 }
190             }
191             if (duration.length() != 0) {
192                 // strip the space off again
193                 duration = duration.substring(1);
194             }
195         }
196         if (suppressTrailingZeroElements) {
197             String tmp = StringUtils.replaceOnce(duration, " 0 seconds", "");
198             if (tmp.length() != duration.length()) {
199                 duration = tmp;
200                 tmp = StringUtils.replaceOnce(duration, " 0 minutes", "");
201                 if (tmp.length() != duration.length()) {
202                     duration = tmp;
203                     tmp = StringUtils.replaceOnce(duration, " 0 hours", "");
204                     if (tmp.length() != duration.length()) {
205                         duration = StringUtils.replaceOnce(tmp, " 0 days", "");
206                     }
207                 }
208             }
209         }
210         // handle plurals
211         duration = " " + duration;
212         duration = StringUtils.replaceOnce(duration, " 1 seconds", " 1 second");
213         duration = StringUtils.replaceOnce(duration, " 1 minutes", " 1 minute");
214         duration = StringUtils.replaceOnce(duration, " 1 hours", " 1 hour");
215         duration = StringUtils.replaceOnce(duration, " 1 days", " 1 day");
216         return duration.trim();
217     }
218 
219     //-----------------------------------------------------------------------
220     /**
221      * <p>Formats the time gap as a string.</p>
222      * 
223      * <p>The format used is the ISO8601 period format.</p>
224      * 
225      * @param startMillis  the start of the duration to format
226      * @param endMillis  the end of the duration to format
227      * @return the formatted duration, not null
228      */
229     public static String formatPeriodISO(final long startMillis, final long endMillis) {
230         return formatPeriod(startMillis, endMillis, ISO_EXTENDED_FORMAT_PATTERN, false, TimeZone.getDefault());
231     }
232 
233     /**
234      * <p>Formats the time gap as a string, using the specified format.
235      * Padding the left hand side of numbers with zeroes is optional.
236      * 
237      * @param startMillis  the start of the duration
238      * @param endMillis  the end of the duration
239      * @param format  the way in which to format the duration, not null
240      * @return the formatted duration, not null
241      */
242     public static String formatPeriod(final long startMillis, final long endMillis, final String format) {
243         return formatPeriod(startMillis, endMillis, format, true, TimeZone.getDefault());
244     }
245 
246     /**
247      * <p>Formats the time gap as a string, using the specified format.
248      * Padding the left hand side of numbers with zeroes is optional and 
249      * the timezone may be specified. </p>
250      *
251      * <p>When calculating the difference between months/days, it chooses to 
252      * calculate months first. So when working out the number of months and 
253      * days between January 15th and March 10th, it choose 1 month and 
254      * 23 days gained by choosing January->February = 1 month and then 
255      * calculating days forwards, and not the 1 month and 26 days gained by 
256      * choosing March -> February = 1 month and then calculating days 
257      * backwards. </p>
258      *
259      * <p>For more control, the <a href="http://joda-time.sf.net/">Joda-Time</a>
260      * library is recommended.</p>
261      * 
262      * @param startMillis  the start of the duration
263      * @param endMillis  the end of the duration
264      * @param format  the way in which to format the duration, not null
265      * @param padWithZeros  whether to pad the left hand side of numbers with 0's
266      * @param timezone  the millis are defined in
267      * @return the formatted duration, not null
268      */
269     public static String formatPeriod(final long startMillis, final long endMillis, final String format, final boolean padWithZeros, 
270             final TimeZone timezone) {
271 
272         // Used to optimise for differences under 28 days and 
273         // called formatDuration(millis, format); however this did not work 
274         // over leap years. 
275         // TODO: Compare performance to see if anything was lost by 
276         // losing this optimisation. 
277         
278         final Token[] tokens = lexx(format);
279 
280         // timezones get funky around 0, so normalizing everything to GMT 
281         // stops the hours being off
282         final Calendar start = Calendar.getInstance(timezone);
283         start.setTime(new Date(startMillis));
284         final Calendar end = Calendar.getInstance(timezone);
285         end.setTime(new Date(endMillis));
286 
287         // initial estimates
288         int milliseconds = end.get(Calendar.MILLISECOND) - start.get(Calendar.MILLISECOND);
289         int seconds = end.get(Calendar.SECOND) - start.get(Calendar.SECOND);
290         int minutes = end.get(Calendar.MINUTE) - start.get(Calendar.MINUTE);
291         int hours = end.get(Calendar.HOUR_OF_DAY) - start.get(Calendar.HOUR_OF_DAY);
292         int days = end.get(Calendar.DAY_OF_MONTH) - start.get(Calendar.DAY_OF_MONTH);
293         int months = end.get(Calendar.MONTH) - start.get(Calendar.MONTH);
294         int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
295 
296         // each initial estimate is adjusted in case it is under 0
297         while (milliseconds < 0) {
298             milliseconds += 1000;
299             seconds -= 1;
300         }
301         while (seconds < 0) {
302             seconds += 60;
303             minutes -= 1;
304         }
305         while (minutes < 0) {
306             minutes += 60;
307             hours -= 1;
308         }
309         while (hours < 0) {
310             hours += 24;
311             days -= 1;
312         }
313        
314         if (Token.containsTokenWithValue(tokens, M)) {
315             while (days < 0) {
316                 days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
317                 months -= 1;
318                 start.add(Calendar.MONTH, 1);
319             }
320 
321             while (months < 0) {
322                 months += 12;
323                 years -= 1;
324             }
325 
326             if (!Token.containsTokenWithValue(tokens, y) && years != 0) {
327                 while (years != 0) {
328                     months += 12 * years;
329                     years = 0;
330                 }
331             }
332         } else {
333             // there are no M's in the format string
334 
335             if( !Token.containsTokenWithValue(tokens, y) ) {
336                 int target = end.get(Calendar.YEAR);
337                 if (months < 0) {
338                     // target is end-year -1
339                     target -= 1;
340                 }
341                 
342                 while ( (start.get(Calendar.YEAR) != target)) {
343                     days += start.getActualMaximum(Calendar.DAY_OF_YEAR) - start.get(Calendar.DAY_OF_YEAR);
344                     
345                     // Not sure I grok why this is needed, but the brutal tests show it is
346                     if (start instanceof GregorianCalendar &&
347                             start.get(Calendar.MONTH) == Calendar.FEBRUARY &&
348                             start.get(Calendar.DAY_OF_MONTH) == 29) {
349                         days += 1;
350                     }
351                     
352                     start.add(Calendar.YEAR, 1);
353                     
354                     days += start.get(Calendar.DAY_OF_YEAR);
355                 }
356                 
357                 years = 0;
358             }
359             
360             while( start.get(Calendar.MONTH) != end.get(Calendar.MONTH) ) {
361                 days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
362                 start.add(Calendar.MONTH, 1);
363             }
364             
365             months = 0;            
366 
367             while (days < 0) {
368                 days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
369                 months -= 1;
370                 start.add(Calendar.MONTH, 1);
371             }
372             
373         }
374 
375         // The rest of this code adds in values that 
376         // aren't requested. This allows the user to ask for the 
377         // number of months and get the real count and not just 0->11.
378 
379         if (!Token.containsTokenWithValue(tokens, d)) {
380             hours += 24 * days;
381             days = 0;
382         }
383         if (!Token.containsTokenWithValue(tokens, H)) {
384             minutes += 60 * hours;
385             hours = 0;
386         }
387         if (!Token.containsTokenWithValue(tokens, m)) {
388             seconds += 60 * minutes;
389             minutes = 0;
390         }
391         if (!Token.containsTokenWithValue(tokens, s)) {
392             milliseconds += 1000 * seconds;
393             seconds = 0;
394         }
395 
396         return format(tokens, years, months, days, hours, minutes, seconds, milliseconds, padWithZeros);
397     }
398 
399     //-----------------------------------------------------------------------
400     /**
401      * <p>The internal method to do the formatting.</p>
402      * 
403      * @param tokens  the tokens
404      * @param years  the number of years
405      * @param months  the number of months
406      * @param days  the number of days
407      * @param hours  the number of hours
408      * @param minutes  the number of minutes
409      * @param seconds  the number of seconds
410      * @param milliseconds  the number of millis
411      * @param padWithZeros  whether to pad
412      * @return the formatted string
413      */
414     static String format(final Token[] tokens, final int years, final int months, final int days, final int hours, final int minutes, final int seconds,
415             int milliseconds, final boolean padWithZeros) {
416         final StringBuilder buffer = new StringBuilder();
417         boolean lastOutputSeconds = false;
418         final int sz = tokens.length;
419         for (int i = 0; i < sz; i++) {
420             final Token token = tokens[i];
421             final Object value = token.getValue();
422             final int count = token.getCount();
423             if (value instanceof StringBuilder) {
424                 buffer.append(value.toString());
425             } else {
426                 if (value == y) {
427                     buffer.append(padWithZeros ? StringUtils.leftPad(Integer.toString(years), count, '0') : Integer
428                             .toString(years));
429                     lastOutputSeconds = false;
430                 } else if (value == M) {
431                     buffer.append(padWithZeros ? StringUtils.leftPad(Integer.toString(months), count, '0') : Integer
432                             .toString(months));
433                     lastOutputSeconds = false;
434                 } else if (value == d) {
435                     buffer.append(padWithZeros ? StringUtils.leftPad(Integer.toString(days), count, '0') : Integer
436                             .toString(days));
437                     lastOutputSeconds = false;
438                 } else if (value == H) {
439                     buffer.append(padWithZeros ? StringUtils.leftPad(Integer.toString(hours), count, '0') : Integer
440                             .toString(hours));
441                     lastOutputSeconds = false;
442                 } else if (value == m) {
443                     buffer.append(padWithZeros ? StringUtils.leftPad(Integer.toString(minutes), count, '0') : Integer
444                             .toString(minutes));
445                     lastOutputSeconds = false;
446                 } else if (value == s) {
447                     buffer.append(padWithZeros ? StringUtils.leftPad(Integer.toString(seconds), count, '0') : Integer
448                             .toString(seconds));
449                     lastOutputSeconds = true;
450                 } else if (value == S) {
451                     if (lastOutputSeconds) {
452                         milliseconds += 1000;
453                         final String str = padWithZeros
454                                 ? StringUtils.leftPad(Integer.toString(milliseconds), count, '0')
455                                 : Integer.toString(milliseconds);
456                         buffer.append(str.substring(1));
457                     } else {
458                         buffer.append(padWithZeros
459                                 ? StringUtils.leftPad(Integer.toString(milliseconds), count, '0')
460                                 : Integer.toString(milliseconds));
461                     }
462                     lastOutputSeconds = false;
463                 }
464             }
465         }
466         return buffer.toString();
467     }
468 
469     static final Object y = "y";
470     static final Object M = "M";
471     static final Object d = "d";
472     static final Object H = "H";
473     static final Object m = "m";
474     static final Object s = "s";
475     static final Object S = "S";
476     
477     /**
478      * Parses a classic date format string into Tokens
479      *
480      * @param format  the format to parse, not null
481      * @return array of Token[]
482      */
483     static Token[] lexx(final String format) {
484         final char[] array = format.toCharArray();
485         final ArrayList<Token> list = new ArrayList<Token>(array.length);
486 
487         boolean inLiteral = false;
488         // Although the buffer is stored in a Token, the Tokens are only
489         // used internally, so cannot be accessed by other threads
490         StringBuilder buffer = null;
491         Token previous = null;
492         final int sz = array.length;
493         for(int i=0; i<sz; i++) {
494             final char ch = array[i];
495             if(inLiteral && ch != '\'') {
496                 buffer.append(ch); // buffer can't be null if inLiteral is true
497                 continue;
498             }
499             Object value = null;
500             switch(ch) {
501                 // TODO: Need to handle escaping of '
502                 case '\'' : 
503                   if(inLiteral) {
504                       buffer = null;
505                       inLiteral = false;
506                   } else {
507                       buffer = new StringBuilder();
508                       list.add(new Token(buffer));
509                       inLiteral = true;
510                   }
511                   break;
512                 case 'y'  : value = y; break;
513                 case 'M'  : value = M; break;
514                 case 'd'  : value = d; break;
515                 case 'H'  : value = H; break;
516                 case 'm'  : value = m; break;
517                 case 's'  : value = s; break;
518                 case 'S'  : value = S; break;
519                 default   : 
520                   if(buffer == null) {
521                       buffer = new StringBuilder();
522                       list.add(new Token(buffer));
523                   }
524                   buffer.append(ch);
525             }
526 
527             if(value != null) {
528                 if(previous != null && previous.getValue() == value) {
529                     previous.increment();
530                 } else {
531                     final Token token = new Token(value);
532                     list.add(token); 
533                     previous = token;
534                 }
535                 buffer = null; 
536             }
537         }
538         return list.toArray( new Token[list.size()] );
539     }
540 
541     //-----------------------------------------------------------------------
542     /**
543      * Element that is parsed from the format pattern.
544      */
545     static class Token {
546 
547         /**
548          * Helper method to determine if a set of tokens contain a value
549          *
550          * @param tokens set to look in
551          * @param value to look for
552          * @return boolean <code>true</code> if contained
553          */
554         static boolean containsTokenWithValue(final Token[] tokens, final Object value) {
555             final int sz = tokens.length;
556             for (int i = 0; i < sz; i++) {
557                 if (tokens[i].getValue() == value) {
558                     return true;
559                 }
560             }
561             return false;
562         }
563 
564         private final Object value;
565         private int count;
566 
567         /**
568          * Wraps a token around a value. A value would be something like a 'Y'.
569          *
570          * @param value to wrap
571          */
572         Token(final Object value) {
573             this.value = value;
574             this.count = 1;
575         }
576 
577         /**
578          * Wraps a token around a repeated number of a value, for example it would 
579          * store 'yyyy' as a value for y and a count of 4.
580          *
581          * @param value to wrap
582          * @param count to wrap
583          */
584         Token(final Object value, final int count) {
585             this.value = value;
586             this.count = count;
587         }
588 
589         /**
590          * Adds another one of the value
591          */
592         void increment() { 
593             count++;
594         }
595 
596         /**
597          * Gets the current number of values represented
598          *
599          * @return int number of values represented
600          */
601         int getCount() {
602             return count;
603         }
604 
605         /**
606          * Gets the particular value this token represents.
607          * 
608          * @return Object value
609          */
610         Object getValue() {
611             return value;
612         }
613 
614         /**
615          * Supports equality of this Token to another Token.
616          *
617          * @param obj2 Object to consider equality of
618          * @return boolean <code>true</code> if equal
619          */
620         @Override
621         public boolean equals(final Object obj2) {
622             if (obj2 instanceof Token) {
623                 final Token tok2 = (Token) obj2;
624                 if (this.value.getClass() != tok2.value.getClass()) {
625                     return false;
626                 }
627                 if (this.count != tok2.count) {
628                     return false;
629                 }
630                 if (this.value instanceof StringBuilder) {
631                     return this.value.toString().equals(tok2.value.toString());
632                 } else if (this.value instanceof Number) {
633                     return this.value.equals(tok2.value);
634                 } else {
635                     return this.value == tok2.value;
636                 }
637             }
638             return false;
639         }
640 
641         /**
642          * Returns a hash code for the token equal to the 
643          * hash code for the token's value. Thus 'TT' and 'TTTT' 
644          * will have the same hash code. 
645          *
646          * @return The hash code for the token
647          */
648         @Override
649         public int hashCode() {
650             return this.value.hashCode();
651         }
652 
653         /**
654          * Represents this token as a String.
655          *
656          * @return String representation of the token
657          */
658         @Override
659         public String toString() {
660             return StringUtils.repeat(this.value.toString(), this.count);
661         }
662     }
663 
664 }