001/*
002 * (C) Copyright 2019 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *       Florent Guillaume
018 */
019package org.nuxeo.common.utils;
020
021import static java.time.temporal.ChronoUnit.DAYS;
022import static java.time.temporal.ChronoUnit.MONTHS;
023import static java.time.temporal.ChronoUnit.NANOS;
024import static java.time.temporal.ChronoUnit.SECONDS;
025import static java.time.temporal.ChronoUnit.YEARS;
026
027import java.time.Duration;
028import java.time.Period;
029import java.time.format.DateTimeParseException;
030import java.time.temporal.Temporal;
031import java.time.temporal.TemporalAmount;
032import java.time.temporal.TemporalUnit;
033import java.util.List;
034import java.util.Objects;
035import java.util.regex.Matcher;
036import java.util.regex.Pattern;
037
038/**
039 * The combination of a {@link Period} and a {@link Duration}.
040 * <p>
041 * This allows the representation of ISO 8601 "durations", which comprise a nominal duration (Java {@link Period}, i.e.,
042 * years, months, days), and an accurate duration (Java {@link Duration}, i.e., hours, minutes, seconds).
043 *
044 * @since 11.1
045 */
046public final class PeriodAndDuration implements TemporalAmount {
047
048    /**
049     * A constant for a period and duration of zero.
050     */
051    public static final PeriodAndDuration ZERO = new PeriodAndDuration(Period.ZERO, Duration.ZERO);
052
053    /**
054     * The set of supported units. This is the concatenation of the units supported by {@link Period} and
055     * {@link Duration}.
056     */
057    protected static final List<TemporalUnit> UNITS = List.of(YEARS, MONTHS, DAYS, SECONDS, NANOS);
058
059    protected static final Pattern PATTERN = Pattern.compile("([-+]?)P" //
060            + "(?:([-+]?[0-9]+)Y)?" //
061            + "(?:([-+]?[0-9]+)M)?" //
062            + "(?:([-+]?[0-9]+)D)?" //
063            + "(T" //
064            + "(?:([-+]?[0-9]+)H)?" //
065            + "(?:([-+]?[0-9]+)M)?" //
066            + "(?:([-+]?[0-9]+)(?:[.,]([0-9]{0,9}))?S)?" //
067            + ")?", //
068            Pattern.CASE_INSENSITIVE);
069
070    protected static final int SECONDS_PER_MINUTE = 60;
071
072    protected static final int SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
073
074    public final Period period;
075
076    public final Duration duration;
077
078    /**
079     * Constructs a {@link PeriodAndDuration} from the given period and duration.
080     */
081    public PeriodAndDuration(Period period, Duration duration) {
082        Objects.requireNonNull(period, "period");
083        Objects.requireNonNull(duration, "duration");
084        this.period = period;
085        this.duration = duration;
086    }
087
088    /**
089     * Constructs a {@link PeriodAndDuration} from the given period.
090     */
091    public PeriodAndDuration(Period period) {
092        this(period, Duration.ZERO);
093    }
094
095    /**
096     * Constructs a {@link PeriodAndDuration} from the given duration.
097     */
098    public PeriodAndDuration(Duration duration) {
099        this(Period.ZERO, duration);
100    }
101
102    @Override
103    public List<TemporalUnit> getUnits() {
104        return UNITS;
105    }
106
107    @Override
108    public long get(TemporalUnit unit) {
109        if (unit == YEARS || unit == MONTHS || unit == DAYS) {
110            return period.get(unit);
111        } else {
112            return duration.get(unit);
113        }
114    }
115
116    @Override
117    public Temporal addTo(Temporal temporal) {
118        return temporal.plus(period).plus(duration);
119    }
120
121    @Override
122    public Temporal subtractFrom(Temporal temporal) {
123        return temporal.minus(period).minus(duration);
124    }
125
126    /**
127     * Obtains a {@code PeriodAndDuration} from a text string.
128     * <p>
129     * This will parse the string based on the ISO-8601 period format {@code PnYnMnDTnHnMnS}. A leading minus sign, and
130     * negative values for the units, are allowed.
131     *
132     * @param text the text to parse, not {@code null}
133     * @return the period and duration (never {@code null})
134     * @throws DateTimeParseException if the text cannot be parsed to a period and duration
135     * @see #toString
136     */
137    public static PeriodAndDuration parse(String text) {
138        Objects.requireNonNull(text, "text");
139        Matcher matcher = PATTERN.matcher(text);
140        if (matcher.matches()) {
141            // check for letter T but no time sections
142            if (!"T".equals(matcher.group(5))) {
143                boolean negate = "-".equals(matcher.group(1));
144                String yearMatch = matcher.group(2);
145                String monthMatch = matcher.group(3);
146                String dayMatch = matcher.group(4);
147                String hourMatch = matcher.group(6);
148                String minuteMatch = matcher.group(7);
149                String secondMatch = matcher.group(8);
150                String fractionMatch = matcher.group(9);
151                if (yearMatch != null || monthMatch != null || dayMatch != null || hourMatch != null
152                        || minuteMatch != null || secondMatch != null) {
153                    int years = parseInt(yearMatch, text, "years");
154                    int months = parseInt(monthMatch, text, "months");
155                    int days = parseInt(dayMatch, text, "days");
156                    long hoursAsSecs = parseNumber(hourMatch, SECONDS_PER_HOUR, text, "hours");
157                    long minsAsSecs = parseNumber(minuteMatch, SECONDS_PER_MINUTE, text, "minutes");
158                    long seconds = parseNumber(secondMatch, 1, text, "seconds");
159                    int nanos = parseFraction(fractionMatch, Long.signum(seconds), text);
160                    try {
161                        Period period = Period.of(years, months, days);
162                        if (negate) {
163                            period = period.negated();
164                        }
165                        seconds = Math.addExact(hoursAsSecs, Math.addExact(minsAsSecs, seconds));
166                        Duration duration = Duration.ofSeconds(seconds, nanos);
167                        if (negate) {
168                            duration = duration.negated();
169                        }
170                        return new PeriodAndDuration(period, duration);
171                    } catch (ArithmeticException e) {
172                        throw new DateTimeParseException("Text cannot be parsed to a PeriodAndDuration: overflow", text,
173                                0, e);
174                    }
175                }
176            }
177        }
178        throw new DateTimeParseException("Text cannot be parsed to a PeriodAndDuration", text, 0);
179    }
180
181    protected static int parseInt(String string, String text, String errorText) {
182        if (string == null) {
183            return 0;
184        }
185        try {
186            return Integer.parseInt(string);
187        } catch (NumberFormatException e) {
188            throw new DateTimeParseException("Text cannot be parsed to a PeriodAndDuration: " + errorText, text, 0, e);
189        }
190    }
191
192    protected static long parseNumber(String string, int multiplier, String text, String errorText) {
193        if (string == null) {
194            return 0;
195        }
196        try {
197            long val = Long.parseLong(string);
198            return Math.multiplyExact(val, multiplier);
199        } catch (NumberFormatException | ArithmeticException e) {
200            throw new DateTimeParseException("Text cannot be parsed to a PeriodAndDuration: " + errorText, text, 0, e);
201        }
202    }
203
204    protected static int parseFraction(String string, int sign, String text) {
205        if (string == null || string.length() == 0) {
206            return 0;
207        }
208        try {
209            string = (string + "000000000").substring(0, 9);
210            return Integer.parseInt(string) * sign;
211        } catch (NumberFormatException | ArithmeticException e) {
212            throw new DateTimeParseException("Text cannot be parsed to a PeriodAndDuration: fraction", text, 0, e);
213        }
214    }
215
216    /**
217     * Outputs this period and duration as a {@code String}, such as {@code P6Y3M1DT4H12M5.636224S}.
218     * <p>
219     * The output will be in the ISO-8601 period format. A zero period will be represented as zero seconds, "PT0S".
220     *
221     * @return a string representation of this period, not {@code null}
222     * @see #parse
223     */
224    @Override
225    public String toString() {
226        if (period.isZero()) {
227            if (duration.getSeconds() < 0) {
228                return "-" + duration.negated().toString();
229            } else {
230                return duration.toString();
231            }
232        }
233        if (duration.isZero()) {
234            if (period.getYears() <= 0 && period.getMonths() <= 0 && period.getDays() <= 0) {
235                return "-" + period.negated().toString();
236            } else {
237                return period.toString();
238            }
239        }
240        StringBuilder sb = new StringBuilder();
241        int i;
242        if (duration.getSeconds() <= 0 && period.getYears() <= 0 && period.getMonths() <= 0 && period.getDays() <= 0) {
243            // factor out minus sign
244            sb.append("-");
245            sb.append(period.negated().toString());
246            i = sb.length();
247            sb.append(duration.negated().toString());
248        } else {
249            sb.append(period.toString());
250            i = sb.length();
251            sb.append(duration.toString());
252        }
253        sb.deleteCharAt(i); // remove spurious second "P" from duration
254        return sb.toString();
255    }
256
257}