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}