001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.lang3.time;
018
019import java.util.ArrayList;
020import java.util.Calendar;
021import java.util.Date;
022import java.util.GregorianCalendar;
023import java.util.TimeZone;
024
025import org.apache.commons.lang3.StringUtils;
026import org.apache.commons.lang3.Validate;
027
028/**
029 * <p>Duration formatting utilities and constants. The following table describes the tokens
030 * used in the pattern language for formatting. </p>
031 * <table border="1" summary="Pattern Tokens">
032 *  <tr><th>character</th><th>duration element</th></tr>
033 *  <tr><td>y</td><td>years</td></tr>
034 *  <tr><td>M</td><td>months</td></tr>
035 *  <tr><td>d</td><td>days</td></tr>
036 *  <tr><td>H</td><td>hours</td></tr>
037 *  <tr><td>m</td><td>minutes</td></tr>
038 *  <tr><td>s</td><td>seconds</td></tr>
039 *  <tr><td>S</td><td>milliseconds</td></tr>
040 *  <tr><td>'text'</td><td>arbitrary text content</td></tr>
041 * </table>
042 *
043 * <b>Note: It's not currently possible to include a single-quote in a format.</b>
044 * <br>
045 * Token values are printed using decimal digits.
046 * A token character can be repeated to ensure that the field occupies a certain minimum
047 * size. Values will be left-padded with 0 unless padding is disabled in the method invocation.
048 * @since 2.1
049 */
050public class DurationFormatUtils {
051
052    /**
053     * <p>DurationFormatUtils instances should NOT be constructed in standard programming.</p>
054     *
055     * <p>This constructor is public to permit tools that require a JavaBean instance
056     * to operate.</p>
057     */
058    public DurationFormatUtils() {
059        super();
060    }
061
062    /**
063     * <p>Pattern used with <code>FastDateFormat</code> and <code>SimpleDateFormat</code>
064     * for the ISO 8601 period format used in durations.</p>
065     *
066     * @see org.apache.commons.lang3.time.FastDateFormat
067     * @see java.text.SimpleDateFormat
068     */
069    public static final String ISO_EXTENDED_FORMAT_PATTERN = "'P'yyyy'Y'M'M'd'DT'H'H'm'M's.SSS'S'";
070
071    //-----------------------------------------------------------------------
072    /**
073     * <p>Formats the time gap as a string.</p>
074     *
075     * <p>The format used is ISO 8601-like: {@code HH:mm:ss.SSS}.</p>
076     *
077     * @param durationMillis  the duration to format
078     * @return the formatted duration, not null
079     * @throws java.lang.IllegalArgumentException if durationMillis is negative
080     */
081    public static String formatDurationHMS(final long durationMillis) {
082        return formatDuration(durationMillis, "HH:mm:ss.SSS");
083    }
084
085    /**
086     * <p>Formats the time gap as a string.</p>
087     *
088     * <p>The format used is the ISO 8601 period format.</p>
089     *
090     * <p>This method formats durations using the days and lower fields of the
091     * ISO format pattern, such as P7D6TH5M4.321S.</p>
092     *
093     * @param durationMillis  the duration to format
094     * @return the formatted duration, not null
095     * @throws java.lang.IllegalArgumentException if durationMillis is negative
096     */
097    public static String formatDurationISO(final long durationMillis) {
098        return formatDuration(durationMillis, ISO_EXTENDED_FORMAT_PATTERN, false);
099    }
100
101    /**
102     * <p>Formats the time gap as a string, using the specified format, and padding with zeros.</p>
103     *
104     * <p>This method formats durations using the days and lower fields of the
105     * format pattern. Months and larger are not used.</p>
106     *
107     * @param durationMillis  the duration to format
108     * @param format  the way in which to format the duration, not null
109     * @return the formatted duration, not null
110     * @throws java.lang.IllegalArgumentException if durationMillis is negative
111     */
112    public static String formatDuration(final long durationMillis, final String format) {
113        return formatDuration(durationMillis, format, true);
114    }
115
116    /**
117     * <p>Formats the time gap as a string, using the specified format.
118     * Padding the left hand side of numbers with zeroes is optional.</p>
119     *
120     * <p>This method formats durations using the days and lower fields of the
121     * format pattern. Months and larger are not used.</p>
122     *
123     * @param durationMillis  the duration to format
124     * @param format  the way in which to format the duration, not null
125     * @param padWithZeros  whether to pad the left hand side of numbers with 0's
126     * @return the formatted duration, not null
127     * @throws java.lang.IllegalArgumentException if durationMillis is negative
128     */
129    public static String formatDuration(final long durationMillis, final String format, final boolean padWithZeros) {
130        Validate.inclusiveBetween(0, Long.MAX_VALUE, durationMillis, "durationMillis must not be negative");
131
132        final Token[] tokens = lexx(format);
133
134        long days         = 0;
135        long hours        = 0;
136        long minutes      = 0;
137        long seconds      = 0;
138        long milliseconds = durationMillis;
139
140        if (Token.containsTokenWithValue(tokens, d) ) {
141            days = milliseconds / DateUtils.MILLIS_PER_DAY;
142            milliseconds = milliseconds - (days * DateUtils.MILLIS_PER_DAY);
143        }
144        if (Token.containsTokenWithValue(tokens, H) ) {
145            hours = milliseconds / DateUtils.MILLIS_PER_HOUR;
146            milliseconds = milliseconds - (hours * DateUtils.MILLIS_PER_HOUR);
147        }
148        if (Token.containsTokenWithValue(tokens, m) ) {
149            minutes = milliseconds / DateUtils.MILLIS_PER_MINUTE;
150            milliseconds = milliseconds - (minutes * DateUtils.MILLIS_PER_MINUTE);
151        }
152        if (Token.containsTokenWithValue(tokens, s) ) {
153            seconds = milliseconds / DateUtils.MILLIS_PER_SECOND;
154            milliseconds = milliseconds - (seconds * DateUtils.MILLIS_PER_SECOND);
155        }
156
157        return format(tokens, 0, 0, days, hours, minutes, seconds, milliseconds, padWithZeros);
158    }
159
160    /**
161     * <p>Formats an elapsed time into a pluralization correct string.</p>
162     *
163     * <p>This method formats durations using the days and lower fields of the
164     * format pattern. Months and larger are not used.</p>
165     *
166     * @param durationMillis  the elapsed time to report in milliseconds
167     * @param suppressLeadingZeroElements  suppresses leading 0 elements
168     * @param suppressTrailingZeroElements  suppresses trailing 0 elements
169     * @return the formatted text in days/hours/minutes/seconds, not null
170     * @throws java.lang.IllegalArgumentException if durationMillis is negative
171     */
172    public static String formatDurationWords(
173        final long durationMillis,
174        final boolean suppressLeadingZeroElements,
175        final boolean suppressTrailingZeroElements) {
176
177        // This method is generally replaceable by the format method, but
178        // there are a series of tweaks and special cases that require
179        // trickery to replicate.
180        String duration = formatDuration(durationMillis, "d' days 'H' hours 'm' minutes 's' seconds'");
181        if (suppressLeadingZeroElements) {
182            // this is a temporary marker on the front. Like ^ in regexp.
183            duration = " " + duration;
184            String tmp = StringUtils.replaceOnce(duration, " 0 days", StringUtils.EMPTY);
185            if (tmp.length() != duration.length()) {
186                duration = tmp;
187                tmp = StringUtils.replaceOnce(duration, " 0 hours", StringUtils.EMPTY);
188                if (tmp.length() != duration.length()) {
189                    duration = tmp;
190                    tmp = StringUtils.replaceOnce(duration, " 0 minutes", StringUtils.EMPTY);
191                    duration = tmp;
192                    if (tmp.length() != duration.length()) {
193                        duration = StringUtils.replaceOnce(tmp, " 0 seconds", StringUtils.EMPTY);
194                    }
195                }
196            }
197            if (duration.length() != 0) {
198                // strip the space off again
199                duration = duration.substring(1);
200            }
201        }
202        if (suppressTrailingZeroElements) {
203            String tmp = StringUtils.replaceOnce(duration, " 0 seconds", StringUtils.EMPTY);
204            if (tmp.length() != duration.length()) {
205                duration = tmp;
206                tmp = StringUtils.replaceOnce(duration, " 0 minutes", StringUtils.EMPTY);
207                if (tmp.length() != duration.length()) {
208                    duration = tmp;
209                    tmp = StringUtils.replaceOnce(duration, " 0 hours", StringUtils.EMPTY);
210                    if (tmp.length() != duration.length()) {
211                        duration = StringUtils.replaceOnce(tmp, " 0 days", StringUtils.EMPTY);
212                    }
213                }
214            }
215        }
216        // handle plurals
217        duration = " " + duration;
218        duration = StringUtils.replaceOnce(duration, " 1 seconds", " 1 second");
219        duration = StringUtils.replaceOnce(duration, " 1 minutes", " 1 minute");
220        duration = StringUtils.replaceOnce(duration, " 1 hours", " 1 hour");
221        duration = StringUtils.replaceOnce(duration, " 1 days", " 1 day");
222        return duration.trim();
223    }
224
225    //-----------------------------------------------------------------------
226    /**
227     * <p>Formats the time gap as a string.</p>
228     *
229     * <p>The format used is the ISO 8601 period format.</p>
230     *
231     * @param startMillis  the start of the duration to format
232     * @param endMillis  the end of the duration to format
233     * @return the formatted duration, not null
234     * @throws java.lang.IllegalArgumentException if startMillis is greater than endMillis
235     */
236    public static String formatPeriodISO(final long startMillis, final long endMillis) {
237        return formatPeriod(startMillis, endMillis, ISO_EXTENDED_FORMAT_PATTERN, false, TimeZone.getDefault());
238    }
239
240    /**
241     * <p>Formats the time gap as a string, using the specified format.
242     * Padding the left hand side of numbers with zeroes is optional.
243     *
244     * @param startMillis  the start of the duration
245     * @param endMillis  the end of the duration
246     * @param format  the way in which to format the duration, not null
247     * @return the formatted duration, not null
248     * @throws java.lang.IllegalArgumentException if startMillis is greater than endMillis
249     */
250    public static String formatPeriod(final long startMillis, final long endMillis, final String format) {
251        return formatPeriod(startMillis, endMillis, format, true, TimeZone.getDefault());
252    }
253
254    /**
255     * <p>Formats the time gap as a string, using the specified format.
256     * Padding the left hand side of numbers with zeroes is optional and
257     * the timezone may be specified. </p>
258     *
259     * <p>When calculating the difference between months/days, it chooses to
260     * calculate months first. So when working out the number of months and
261     * days between January 15th and March 10th, it choose 1 month and
262     * 23 days gained by choosing January-&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<>(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}