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