diff --git a/li.strolch.utils/src/main/java/li/strolch/utils/I18nStrolchUtilsBundle.java b/li.strolch.utils/src/main/java/li/strolch/utils/I18nStrolchUtilsBundle.java new file mode 100644 index 000000000..7e781fa83 --- /dev/null +++ b/li.strolch.utils/src/main/java/li/strolch/utils/I18nStrolchUtilsBundle.java @@ -0,0 +1,14 @@ +package li.strolch.utils; + +import java.util.Locale; +import java.util.ResourceBundle; + +public class I18nStrolchUtilsBundle { + + public static String i18n(Locale locale, String key) { + ResourceBundle bundle = ResourceBundle.getBundle("strolch-utils", locale); + if (bundle.containsKey(key)) + return bundle.getString(key); + return key; + } +} diff --git a/li.strolch.utils/src/main/java/li/strolch/utils/time/Interval.java b/li.strolch.utils/src/main/java/li/strolch/utils/time/Interval.java new file mode 100644 index 000000000..917f17ff6 --- /dev/null +++ b/li.strolch.utils/src/main/java/li/strolch/utils/time/Interval.java @@ -0,0 +1,590 @@ +/* + * Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of JSR-310 nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package li.strolch.utils.time; + +import java.io.Serializable; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.util.Objects; + +/** + * An immutable interval of time between two instants. + *

+ * An interval represents the time on the time-line between two {@link Instant}s. + * The class stores the start and end instants, with the start inclusive and the end exclusive. + * The end instant is always greater than or equal to the start instant. + *

+ * The {@link Duration} of an interval can be obtained, but is a separate concept. + * An interval is connected to the time-line, whereas a duration is not. + *

+ * Intervals are not comparable. To compare the length of two intervals, it is + * generally recommended to compare their durations. + * + *

Implementation Requirements:

+ * This class is immutable and thread-safe. + *

+ * This class must be treated as a value type. Do not synchronize, rely on the + * identity hash code or use the distinction between equals() and ==. + */ +public final class Interval + implements Serializable { + + /** + * An interval over the whole time-line. + */ + public static final Interval ALL = new Interval(Instant.MIN, Instant.MAX); + + /** + * Serialization version. + */ + private static final long serialVersionUID = 8375285238652L; + + /** + * The start instant (inclusive). + */ + private final Instant start; + /** + * The end instant (exclusive). + */ + private final Instant end; + + //----------------------------------------------------------------------- + /** + * Obtains an instance of {@code Interval} from the start and end instant. + *

+ * The end instant must not be before the start instant. + * + * @param startInclusive the start instant, inclusive, MIN_DATE treated as unbounded, not null + * @param endExclusive the end instant, exclusive, MAX_DATE treated as unbounded, not null + * @return the half-open interval, not null + * @throws DateTimeException if the end is before the start + */ + public static Interval of(Instant startInclusive, Instant endExclusive) { + Objects.requireNonNull(startInclusive, "startInclusive"); + Objects.requireNonNull(endExclusive, "endExclusive"); + if (endExclusive.isBefore(startInclusive)) { + throw new DateTimeException("End instant must be equal or after start instant"); + } + return new Interval(startInclusive, endExclusive); + } + + /** + * Obtains an instance of {@code Interval} from the start and a duration. + *

+ * The end instant is calculated as the start plus the duration. + * The duration must not be negative. + * + * @param startInclusive the start instant, inclusive, not null + * @param duration the duration from the start to the end, not null + * @return the interval, not null + * @throws DateTimeException if the end is before the start, + * or if the duration addition cannot be made + * @throws ArithmeticException if numeric overflow occurs when adding the duration + */ + public static Interval of(Instant startInclusive, Duration duration) { + Objects.requireNonNull(startInclusive, "startInclusive"); + Objects.requireNonNull(duration, "duration"); + if (duration.isNegative()) { + throw new DateTimeException("Duration must not be negative"); + } + return new Interval(startInclusive, startInclusive.plus(duration)); + } + + //----------------------------------------------------------------------- + /** + * Obtains an instance of {@code Interval} from a text string such as + * {@code 2007-12-03T10:15:30Z/2007-12-04T10:15:30Z}, where the end instant is exclusive. + *

+ * The string must consist of one of the following four formats: + *

+ *

+ * ISO-8601 supports a very wide range of possible inputs, many of which are not supported here. + * For example, basic format, week-based dates, ordinal dates and date-style period formats are not supported. + * + * @param text the text to parse, not null + * @return the parsed interval, not null + * @throws DateTimeParseException if the text cannot be parsed + */ + public static Interval parse(CharSequence text) { + Objects.requireNonNull(text, "text"); + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '/') { + return parseSplit(text.subSequence(0, i), text.subSequence(i + 1, text.length())); + } + } + throw new DateTimeParseException("Interval cannot be parsed, no forward slash found", text, 0); + } + + private static Interval parseSplit(CharSequence startStr, CharSequence endStr) { + char firstChar = startStr.charAt(0); + if (firstChar == 'P' || firstChar == 'p') { + // duration followed by instant + PeriodDuration amount = PeriodDuration.parse(startStr); + try { + OffsetDateTime end = OffsetDateTime.parse(endStr); + return Interval.of(end.minus(amount).toInstant(), end.toInstant()); + } catch (DateTimeParseException ex) { + // handle case where Instant is outside the bounds of OffsetDateTime + Instant end = Instant.parse(endStr); + // addition of PeriodDuration only supported by OffsetDateTime, + // but to make that work need to move point being subtracted from closer to EPOCH + long move = end.isBefore(Instant.EPOCH) ? 1000 * 86400 : -1000 * 86400; + Instant start = end.plusSeconds(move).atOffset(ZoneOffset.UTC).minus(amount).toInstant().minusSeconds(move); + return Interval.of(start, end); + } + } + // instant followed by instant or duration + OffsetDateTime start; + try { + start = OffsetDateTime.parse(startStr); + } catch (DateTimeParseException ex) { + return parseStartExtended(startStr, endStr); + } + if (endStr.length() > 0) { + char c = endStr.charAt(0); + if (c == 'P' || c == 'p') { + PeriodDuration amount = PeriodDuration.parse(endStr); + return Interval.of(start.toInstant(), start.plus(amount).toInstant()); + } + } + return parseEndDateTime(start.toInstant(), start.getOffset(), endStr); + } + + // handle case where Instant is outside the bounds of OffsetDateTime + private static Interval parseStartExtended(CharSequence startStr, CharSequence endStr) { + Instant start = Instant.parse(startStr); + if (endStr.length() > 0) { + char c = endStr.charAt(0); + if (c == 'P' || c == 'p') { + PeriodDuration amount = PeriodDuration.parse(endStr); + // addition of PeriodDuration only supported by OffsetDateTime, + // but to make that work need to move point being added to closer to EPOCH + long move = start.isBefore(Instant.EPOCH) ? 1000 * 86400 : -1000 * 86400; + Instant end = start.plusSeconds(move).atOffset(ZoneOffset.UTC).plus(amount).toInstant().minusSeconds(move); + return Interval.of(start, end); + } + } + // infer offset from start if not specified by end + return parseEndDateTime(start, ZoneOffset.UTC, endStr); + } + + // parse when there are two date-times + private static Interval parseEndDateTime(Instant start, ZoneOffset offset, CharSequence endStr) { + try { + TemporalAccessor temporal = DateTimeFormatter.ISO_DATE_TIME.parseBest(endStr, OffsetDateTime::from, LocalDateTime::from); + if (temporal instanceof OffsetDateTime) { + OffsetDateTime odt = (OffsetDateTime) temporal; + return Interval.of(start, odt.toInstant()); + } else { + // infer offset from start if not specified by end + LocalDateTime ldt = (LocalDateTime) temporal; + return Interval.of(start, ldt.toInstant(offset)); + } + } catch (DateTimeParseException ex) { + Instant end = Instant.parse(endStr); + return Interval.of(start, end); + } + } + + //----------------------------------------------------------------------- + /** + * Constructor. + * + * @param startInclusive the start instant, inclusive, validated not null + * @param endExclusive the end instant, exclusive, validated not null + */ + private Interval(Instant startInclusive, Instant endExclusive) { + this.start = startInclusive; + this.end = endExclusive; + } + + //----------------------------------------------------------------------- + /** + * Gets the start of this time interval, inclusive. + *

+ * This will return {@link Instant#MIN} if the range is unbounded at the start. + * In this case, the range includes all dates into the far-past. + * + * @return the start of the time interval + */ + public Instant getStart() { + return start; + } + + /** + * Gets the end of this time interval, exclusive. + *

+ * This will return {@link Instant#MAX} if the range is unbounded at the end. + * In this case, the range includes all dates into the far-future. + * + * @return the end of the time interval, exclusive + */ + public Instant getEnd() { + return end; + } + + //----------------------------------------------------------------------- + /** + * Checks if the range is empty. + *

+ * An empty range occurs when the start date equals the inclusive end date. + * + * @return true if the range is empty + */ + public boolean isEmpty() { + return start.equals(end); + } + + /** + * Checks if the start of the interval is unbounded. + * + * @return true if start is unbounded + */ + public boolean isUnboundedStart() { + return start.equals(Instant.MIN); + } + + /** + * Checks if the end of the interval is unbounded. + * + * @return true if end is unbounded + */ + public boolean isUnboundedEnd() { + return end.equals(Instant.MAX); + } + + //----------------------------------------------------------------------- + /** + * Returns a copy of this range with the specified start instant. + * + * @param start the start instant for the new interval, not null + * @return an interval with the end from this interval and the specified start + * @throws DateTimeException if the resulting interval has end before start + */ + public Interval withStart(Instant start) { + return Interval.of(start, end); + } + + /** + * Returns a copy of this range with the specified end instant. + * + * @param end the end instant for the new interval, not null + * @return an interval with the start from this interval and the specified end + * @throws DateTimeException if the resulting interval has end before start + */ + public Interval withEnd(Instant end) { + return Interval.of(start, end); + } + + //----------------------------------------------------------------------- + /** + * Checks if this interval contains the specified instant. + *

+ * This checks if the specified instant is within the bounds of this interval. + * If this range has an unbounded start then {@code contains(Instant#MIN)} returns true. + * If this range has an unbounded end then {@code contains(Instant#MAX)} returns true. + * If this range is empty then this method always returns false. + * + * @param instant the instant, not null + * @return true if this interval contains the instant + */ + public boolean contains(Instant instant) { + Objects.requireNonNull(instant, "instant"); + return start.compareTo(instant) <= 0 && (instant.compareTo(end) < 0 || isUnboundedEnd()); + } + + /** + * Checks if this interval encloses the specified interval. + *

+ * This checks if the bounds of the specified interval are within the bounds of this interval. + * An empty interval encloses itself. + * + * @param other the other interval, not null + * @return true if this interval contains the other interval + */ + public boolean encloses(Interval other) { + Objects.requireNonNull(other, "other"); + return start.compareTo(other.start) <= 0 && other.end.compareTo(end) <= 0; + } + + /** + * Checks if this interval abuts the specified interval. + *

+ * The result is true if the end of this interval is the start of the other, or vice versa. + * An empty interval does not abut itself. + * + * @param other the other interval, not null + * @return true if this interval abuts the other interval + */ + public boolean abuts(Interval other) { + Objects.requireNonNull(other, "other"); + return end.equals(other.start) ^ start.equals(other.end); + } + + /** + * Checks if this interval is connected to the specified interval. + *

+ * The result is true if the two intervals have an enclosed interval in common, even if that interval is empty. + * An empty interval is connected to itself. + *

+ * This is equivalent to {@code (overlaps(other) || abuts(other))}. + * + * @param other the other interval, not null + * @return true if this interval is connected to the other interval + */ + public boolean isConnected(Interval other) { + Objects.requireNonNull(other, "other"); + return this.equals(other) || (start.compareTo(other.end) <= 0 && other.start.compareTo(end) <= 0); + } + + /** + * Checks if this interval overlaps the specified interval. + *

+ * The result is true if the two intervals share some part of the time-line. + * An empty interval overlaps itself. + *

+ * This is equivalent to {@code (isConnected(other) && !abuts(other))}. + * + * @param other the time interval to compare to, null means a zero length interval now + * @return true if the time intervals overlap + */ + public boolean overlaps(Interval other) { + Objects.requireNonNull(other, "other"); + return other.equals(this) || (start.compareTo(other.end) < 0 && other.start.compareTo(end) < 0); + } + + //----------------------------------------------------------------------- + /** + * Calculates the interval that is the intersection of this interval and the specified interval. + *

+ * This finds the intersection of two intervals. + * This throws an exception if the two intervals are not {@linkplain #isConnected(Interval) connected}. + * + * @param other the other interval to check for, not null + * @return the interval that is the intersection of the two intervals + * @throws DateTimeException if the intervals do not connect + */ + public Interval intersection(Interval other) { + Objects.requireNonNull(other, "other"); + if (isConnected(other) == false) { + throw new DateTimeException("Intervals do not connect: " + this + " and " + other); + } + int cmpStart = start.compareTo(other.start); + int cmpEnd = end.compareTo(other.end); + if (cmpStart >= 0 && cmpEnd <= 0) { + return this; + } else if (cmpStart <= 0 && cmpEnd >= 0) { + return other; + } else { + Instant newStart = (cmpStart >= 0 ? start : other.start); + Instant newEnd = (cmpEnd <= 0 ? end : other.end); + return Interval.of(newStart, newEnd); + } + } + + /** + * Calculates the interval that is the union of this interval and the specified interval. + *

+ * This finds the union of two intervals. + * This throws an exception if the two intervals are not {@linkplain #isConnected(Interval) connected}. + * + * @param other the other interval to check for, not null + * @return the interval that is the union of the two intervals + * @throws DateTimeException if the intervals do not connect + */ + public Interval union(Interval other) { + Objects.requireNonNull(other, "other"); + if (isConnected(other) == false) { + throw new DateTimeException("Intervals do not connect: " + this + " and " + other); + } + int cmpStart = start.compareTo(other.start); + int cmpEnd = end.compareTo(other.end); + if (cmpStart >= 0 && cmpEnd <= 0) { + return other; + } else if (cmpStart <= 0 && cmpEnd >= 0) { + return this; + } else { + Instant newStart = (cmpStart >= 0 ? other.start : start); + Instant newEnd = (cmpEnd <= 0 ? other.end : end); + return Interval.of(newStart, newEnd); + } + } + + /** + * Calculates the smallest interval that encloses this interval and the specified interval. + *

+ * The result of this method will {@linkplain #encloses(Interval) enclose} + * this interval and the specified interval. + * + * @param other the other interval to check for, not null + * @return the interval that spans the two intervals + */ + public Interval span(Interval other) { + Objects.requireNonNull(other, "other"); + int cmpStart = start.compareTo(other.start); + int cmpEnd = end.compareTo(other.end); + Instant newStart = (cmpStart >= 0 ? other.start : start); + Instant newEnd = (cmpEnd <= 0 ? other.end : end); + return Interval.of(newStart, newEnd); + } + + //------------------------------------------------------------------------- + /** + * Checks if this interval is after the specified instant. + *

+ * The result is true if this instant starts after the specified instant. + * An empty interval behaves as though it is an instant for comparison purposes. + * + * @param instant the other instant to compare to, not null + * @return true if the start of this interval is after the specified instant + */ + public boolean isAfter(Instant instant) { + return start.compareTo(instant) > 0; + } + + /** + * Checks if this interval is before the specified instant. + *

+ * The result is true if this instant ends before the specified instant. + * Since intervals do not include their end points, this will return true if the + * instant equals the end of the interval. + * An empty interval behaves as though it is an instant for comparison purposes. + * + * @param instant the other instant to compare to, not null + * @return true if the start of this interval is before the specified instant + */ + public boolean isBefore(Instant instant) { + return end.compareTo(instant) <= 0 && start.compareTo(instant) < 0; + } + + //------------------------------------------------------------------------- + /** + * Checks if this interval is after the specified interval. + *

+ * The result is true if this instant starts after the end of the specified interval. + * Since intervals do not include their end points, this will return true if the + * instant equals the end of the interval. + * An empty interval behaves as though it is an instant for comparison purposes. + * + * @param interval the other interval to compare to, not null + * @return true if this instant is after the specified instant + */ + public boolean isAfter(Interval interval) { + return start.compareTo(interval.end) >= 0 && !interval.equals(this); + } + + /** + * Checks if this interval is before the specified interval. + *

+ * The result is true if this instant ends before the start of the specified interval. + * Since intervals do not include their end points, this will return true if the + * two intervals abut. + * An empty interval behaves as though it is an instant for comparison purposes. + * + * @param interval the other interval to compare to, not null + * @return true if this instant is before the specified instant + */ + public boolean isBefore(Interval interval) { + return end.compareTo(interval.start) <= 0 && !interval.equals(this); + } + + //----------------------------------------------------------------------- + /** + * Obtains the duration of this interval. + *

+ * An {@code Interval} is associated with two specific instants on the time-line. + * A {@code Duration} is simply an amount of time, separate from the time-line. + * + * @return the duration of the time interval + * @throws ArithmeticException if the calculation exceeds the capacity of {@code Duration} + */ + public Duration toDuration() { + return Duration.between(start, end); + } + + //----------------------------------------------------------------------- + /** + * Checks if this interval is equal to another interval. + *

+ * Compares this {@code Interval} with another ensuring that the two instants are the same. + * Only objects of type {@code Interval} are compared, other types return false. + * + * @param obj the object to check, null returns false + * @return true if this is equal to the other interval + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof Interval) { + Interval other = (Interval) obj; + return start.equals(other.start) && end.equals(other.end); + } + return false; + } + + /** + * A hash code for this interval. + * + * @return a suitable hash code + */ + @Override + public int hashCode() { + return start.hashCode() ^ end.hashCode(); + } + + //----------------------------------------------------------------------- + /** + * Outputs this interval as a {@code String}, such as {@code 2007-12-03T10:15:30/2007-12-04T10:15:30}. + *

+ * The output will be the ISO-8601 format formed by combining the + * {@code toString()} methods of the two instants, separated by a forward slash. + * + * @return a string representation of this instant, not null + */ + @Override + public String toString() { + return start.toString() + '/' + end.toString(); + } +} \ No newline at end of file diff --git a/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java new file mode 100644 index 000000000..f869bac0a --- /dev/null +++ b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDuration.java @@ -0,0 +1,651 @@ +/* + * Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of JSR-310 nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package li.strolch.utils.time; + +import static java.time.temporal.ChronoUnit.*; + +import java.io.Serializable; +import java.time.*; +import java.time.chrono.ChronoPeriod; +import java.time.chrono.IsoChronology; +import java.time.format.DateTimeParseException; +import java.time.temporal.*; +import java.util.*; + +/** + * An amount of time in the ISO-8601 calendar system that combines a period and a duration. + *

+ * This class models a quantity or amount of time in terms of a {@code Period} and {@code Duration}. + * A period is a date-based amount of time, consisting of years, months and days. + * A duration is a time-based amount of time, consisting of seconds and nanoseconds. + * See the {@link Period} and {@link Duration} classes for more details. + *

+ * The days in a period take account of daylight saving changes (23 or 25 hour days). + * When performing calculations, the period is added first, then the duration. + *

+ * The model is of a directed amount, meaning that the amount may be negative. + * + *

Implementation Requirements:

+ * This class is immutable and thread-safe. + *

+ * This class must be treated as a value type. Do not synchronize, rely on the + * identity hash code or use the distinction between equals() and ==. + */ +public final class PeriodDuration + implements TemporalAmount, Serializable { + + /** + * A constant for a duration of zero. + */ + public static final PeriodDuration ZERO = new PeriodDuration(Period.ZERO, Duration.ZERO); + + /** + * A serialization identifier for this class. + */ + private static final long serialVersionUID = 8815521625671589L; + /** + * The supported units. + */ + private static final List SUPPORTED_UNITS = + Collections.unmodifiableList(Arrays.asList(YEARS, MONTHS, DAYS, SECONDS, NANOS)); + /** + * The number of seconds per day. + */ + private static final long SECONDS_PER_DAY = 86400; + + /** + * The period. + */ + private final Period period; + /** + * The duration. + */ + private final Duration duration; + + //----------------------------------------------------------------------- + /** + * Obtains an instance based on a period and duration. + *

+ * The total amount of time of the resulting instance is the period plus the duration. + * + * @param period the period, not null + * @param duration the duration, not null + * @return the combined period-duration, not null + */ + public static PeriodDuration of(Period period, Duration duration) { + Objects.requireNonNull(period, "The period must not be null"); + Objects.requireNonNull(duration, "The duration must not be null"); + return new PeriodDuration(period, duration); + } + + /** + * Obtains an instance based on a period. + *

+ * The duration will be zero. + * + * @param period the period, not null + * @return the combined period-duration, not null + */ + public static PeriodDuration of(Period period) { + Objects.requireNonNull(period, "The period must not be null"); + return new PeriodDuration(period, Duration.ZERO); + } + + /** + * Obtains an instance based on a duration. + *

+ * The period will be zero. + * + * @param duration the duration, not null + * @return the combined period-duration, not null + */ + public static PeriodDuration of(Duration duration) { + Objects.requireNonNull(duration, "The duration must not be null"); + return new PeriodDuration(Period.ZERO, duration); + } + + //----------------------------------------------------------------------- + /** + * Obtains an instance from a temporal amount. + *

+ * This obtains an instance based on the specified amount. + * A {@code TemporalAmount} represents an amount of time which this factory + * extracts to a {@code PeriodDuration}. + *

+ * The result is calculated by looping around each unit in the specified amount. + * Any amount that is zero is ignore. + * If a unit has an exact duration, it will be totalled using {@link Duration#plus(Duration)}. + * If the unit is days or weeks, it will be totalled into the days part of the period. + * If the unit is months or quarters, it will be totalled into the months part of the period. + * If the unit is years, decades, centuries or millennia, it will be totalled into the years part of the period. + * + * @param amount the temporal amount to convert, not null + * @return the equivalent duration, not null + * @throws DateTimeException if unable to convert to a {@code Duration} + * @throws ArithmeticException if numeric overflow occurs + */ + public static PeriodDuration from(TemporalAmount amount) { + if (amount instanceof PeriodDuration) { + return (PeriodDuration) amount; + } + if (amount instanceof Period) { + return PeriodDuration.of((Period) amount); + } + if (amount instanceof Duration) { + return PeriodDuration.of((Duration) amount); + } + if (amount instanceof ChronoPeriod) { + if (IsoChronology.INSTANCE.equals(((ChronoPeriod) amount).getChronology()) == false) { + throw new DateTimeException("Period requires ISO chronology: " + amount); + } + } + Objects.requireNonNull(amount, "amount"); + int years = 0; + int months = 0; + int days = 0; + Duration duration = Duration.ZERO; + for (TemporalUnit unit : amount.getUnits()) { + long value = amount.get(unit); + if (value != 0) { + // ignore unless non-zero + if (unit.isDurationEstimated()) { + if (unit == ChronoUnit.DAYS) { + days = Math.addExact(days, Math.toIntExact(value)); + } else if (unit == ChronoUnit.WEEKS) { + days = Math.addExact(days, Math.toIntExact(Math.multiplyExact(value, (long) 7))); + } else if (unit == ChronoUnit.MONTHS) { + months = Math.addExact(months, Math.toIntExact(value)); + } else if (unit == IsoFields.QUARTER_YEARS) { + months = Math.addExact(months, Math.toIntExact(Math.multiplyExact(value, (long) 3))); + } else if (unit == ChronoUnit.YEARS) { + years = Math.addExact(years, Math.toIntExact(value)); + } else if (unit == ChronoUnit.DECADES) { + years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 10))); + } else if (unit == ChronoUnit.CENTURIES) { + years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 100))); + } else if (unit == ChronoUnit.MILLENNIA) { + years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, (long) 1000))); + } else { + throw new DateTimeException("Unknown unit: " + unit); + } + } else { + // total of exact durations + duration = duration.plus(amount.get(unit), unit); + } + } + } + return PeriodDuration.of(Period.of(years, months, days), duration); + } + + //----------------------------------------------------------------------- + /** + * Obtains an instance from a text string such as {@code PnYnMnDTnHnMnS}. + *

+ * This will parse the string produced by {@code toString()} which is + * based on the ISO-8601 period formats {@code PnYnMnDTnHnMnS} and {@code PnW}. + *

+ * The string starts with an optional sign, denoted by the ASCII negative + * or positive symbol. If negative, the whole amount is negated. + * The ASCII letter "P" is next in upper or lower case. + * There are then a number of sections, each consisting of a number and a suffix. + * At least one of the sections must be present. + * The sections have suffixes in ASCII of "Y" for years, "M" for months, + * "W" for weeks, "D" for days, "H" for hours, "M" for minutes, "S" for seconds, + * accepted in upper or lower case. Note that the ASCII letter "T" separates + * the date and time parts and must be present if any time part is present. + * The suffixes must occur in order. + * The number part of each section must consist of ASCII digits. + * The number may be prefixed by the ASCII negative or positive symbol. + * The number must parse to an {@code int}. + * Any week-based input is multiplied by 7 and treated as a number of days. + *

+ * The leading plus/minus sign, and negative values for weeks and days are + * not part of the ISO-8601 standard. + *

+ * Note that the date style format {@code PYYYY-MM-DDTHH:MM:SS} is not supported. + *

+ * For example, the following are valid inputs: + *

+     *   "P2Y"             -- PeriodDuration.of(Period.ofYears(2))
+     *   "P3M"             -- PeriodDuration.of(Period.ofMonths(3))
+     *   "P4W"             -- PeriodDuration.of(Period.ofWeeks(4))
+     *   "P5D"             -- PeriodDuration.of(Period.ofDays(5))
+     *   "PT6H"            -- PeriodDuration.of(Duration.ofHours(6))
+     *   "P1Y2M3D"         -- PeriodDuration.of(Period.of(1, 2, 3))
+     *   "P1Y2M3W4DT8H"    -- PeriodDuration.of(Period.of(1, 2, 25), Duration.ofHours(8))
+     *   "P-1Y2M"          -- PeriodDuration.of(Period.of(-1, 2, 0))
+     *   "-P1Y2M"          -- PeriodDuration.of(Period.of(-1, -2, 0))
+     * 
+ * + * @param text the text to parse, not null + * @return the parsed period, not null + * @throws DateTimeParseException if the text cannot be parsed to a period + */ + public static PeriodDuration parse(CharSequence text) { + Objects.requireNonNull(text, "text"); + String upper = text.toString().toUpperCase(Locale.ENGLISH); + String negate = ""; + if (upper.startsWith("+")) { + upper = upper.substring(1); + } else if (upper.startsWith("-")) { + upper = upper.substring(1); + negate = "-"; + } + // duration only, parse original text so it does negation + if (upper.startsWith("PT")) { + return PeriodDuration.of(Duration.parse(text)); + } + // period only, parse original text so it does negation + int tpos = upper.indexOf('T'); + if (tpos < 0) { + return PeriodDuration.of(Period.parse(text)); + } + // period and duration + Period period = Period.parse(negate + upper.substring(0, tpos)); + Duration duration = Duration.parse(negate + "P" + upper.substring(tpos)); + return PeriodDuration.of(period, duration); + } + + //----------------------------------------------------------------------- + /** + * Obtains an instance consisting of the amount of time between two temporals. + *

+ * The start is included, but the end is not. + * The result of this method can be negative if the end is before the start. + *

+ * The calculation examines the temporals and extracts {@link LocalDate} and {@link LocalTime}. + * If the time is missing, it will be defaulted to midnight. + * If one date is missing, it will be defaulted to the other date. + * It then finds the amount of time between the two dates and between the two times. + * + * @param startInclusive the start, inclusive, not null + * @param endExclusive the end, exclusive, not null + * @return the number of days between this date and the end date, not null + */ + public static PeriodDuration between(Temporal startInclusive, Temporal endExclusive) { + LocalDate startDate = startInclusive.query(TemporalQueries.localDate()); + LocalDate endDate = endExclusive.query(TemporalQueries.localDate()); + Period period = Period.ZERO; + if (startDate != null && endDate != null) { + period = Period.between(startDate, endDate); + } + LocalTime startTime = startInclusive.query(TemporalQueries.localTime()); + LocalTime endTime = endExclusive.query(TemporalQueries.localTime()); + startTime = startTime != null ? startTime : LocalTime.MIDNIGHT; + endTime = endTime != null ? endTime : LocalTime.MIDNIGHT; + Duration duration = Duration.between(startTime, endTime); + return PeriodDuration.of(period, duration); + } + + //----------------------------------------------------------------------- + /** + * Constructs an instance. + * + * @param period the period + * @param duration the duration + */ + private PeriodDuration(Period period, Duration duration) { + this.period = period; + this.duration = duration; + } + + /** + * Resolves singletons. + * + * @return the singleton instance + */ + private Object readResolve() { + return PeriodDuration.of(period, duration); + } + + //----------------------------------------------------------------------- + /** + * Gets the value of the requested unit. + *

+ * This returns a value for the supported units - {@link ChronoUnit#YEARS}, + * {@link ChronoUnit#MONTHS}, {@link ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS} + * and {@link ChronoUnit#NANOS}. + * All other units throw an exception. + * Note that hours and minutes throw an exception. + * + * @param unit the {@code TemporalUnit} for which to return the value + * @return the long value of the unit + * @throws UnsupportedTemporalTypeException if the unit is not supported + */ + @Override + public long get(TemporalUnit unit) { + if (unit instanceof ChronoUnit) { + switch ((ChronoUnit) unit) { + case YEARS: + return period.getYears(); + case MONTHS: + return period.getMonths(); + case DAYS: + return period.getDays(); + case SECONDS: + return duration.getSeconds(); + case NANOS: + return duration.getNano(); + default: + break; + } + } + throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit); + } + + /** + * Gets the set of units supported by this amount. + *

+ * This returns the list {@link ChronoUnit#YEARS}, {@link ChronoUnit#MONTHS}, + * {@link ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS} and {@link ChronoUnit#NANOS}. + *

+ * This set can be used in conjunction with {@link #get(TemporalUnit)} + * to access the entire state of the amount. + * + * @return a list containing the days unit, not null + */ + @Override + public List getUnits() { + return SUPPORTED_UNITS; + } + + //----------------------------------------------------------------------- + /** + * Gets the period part. + * + * @return the period part + */ + public Period getPeriod() { + return period; + } + + /** + * Returns a copy of this period-duration with a different period. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param period the new period + * @return the updated period-duration + */ + public PeriodDuration withPeriod(Period period) { + return PeriodDuration.of(period, duration); + } + + /** + * Gets the duration part. + * + * @return the duration part + */ + public Duration getDuration() { + return duration; + } + + /** + * Returns a copy of this period-duration with a different duration. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param duration the new duration + * @return the updated period-duration + */ + public PeriodDuration withDuration(Duration duration) { + return PeriodDuration.of(period, duration); + } + + //----------------------------------------------------------------------- + /** + * Checks if all parts of this amount are zero. + *

+ * This returns true if both {@link Period#isZero()} and {@link Duration#isZero()} + * return true. + * + * @return true if this period is zero-length + */ + public boolean isZero() { + return period.isZero() && duration.isZero(); + } + + //----------------------------------------------------------------------- + /** + * Returns a copy of this amount with the specified amount added. + *

+ * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}. + * The period and duration are combined separately. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param amountToAdd the amount to add, not null + * @return a {@code Days} based on this instance with the requested amount added, not null + * @throws DateTimeException if the specified amount contains an invalid unit + * @throws ArithmeticException if numeric overflow occurs + */ + public PeriodDuration plus(TemporalAmount amountToAdd) { + PeriodDuration other = PeriodDuration.from(amountToAdd); + return of(period.plus(other.period), duration.plus(other.duration)); + } + + //----------------------------------------------------------------------- + /** + * Returns a copy of this amount with the specified amount subtracted. + *

+ * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}. + * The period and duration are combined separately. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param amountToAdd the amount to add, not null + * @return a {@code Days} based on this instance with the requested amount subtracted, not null + * @throws DateTimeException if the specified amount contains an invalid unit + * @throws ArithmeticException if numeric overflow occurs + */ + public PeriodDuration minus(TemporalAmount amountToAdd) { + PeriodDuration other = PeriodDuration.from(amountToAdd); + return of(period.minus(other.period), duration.minus(other.duration)); + } + + //----------------------------------------------------------------------- + /** + * Returns an instance with the amount multiplied by the specified scalar. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param scalar the scalar to multiply by, not null + * @return the amount multiplied by the specified scalar, not null + * @throws ArithmeticException if numeric overflow occurs + */ + public PeriodDuration multipliedBy(int scalar) { + if (scalar == 1) { + return this; + } + return of(period.multipliedBy(scalar), duration.multipliedBy(scalar)); + } + + /** + * Returns an instance with the amount negated. + *

+ * This instance is immutable and unaffected by this method call. + * + * @return the negated amount, not null + * @throws ArithmeticException if numeric overflow occurs, which only happens if + * the amount is {@code Long.MIN_VALUE} + */ + public PeriodDuration negated() { + return multipliedBy(-1); + } + + //----------------------------------------------------------------------- + /** + * Returns a copy of this instance with the years and months exactly normalized. + *

+ * This normalizes the years and months units, leaving the days unit unchanged. + * The result is exact, always representing the same amount of time. + *

+ * The months unit is adjusted to have an absolute value less than 11, + * with the years unit being adjusted to compensate. For example, a period of + * "1 year and 15 months" will be normalized to "2 years and 3 months". + *

+ * The sign of the years and months units will be the same after normalization. + * For example, a period of "1 year and -25 months" will be normalized to + * "-1 year and -1 month". + *

+ * Note that no normalization is performed on the days or duration. + *

+ * This instance is immutable and unaffected by this method call. + * + * @return a {@code PeriodDuration} based on this one with excess months normalized to years, not null + * @throws ArithmeticException if numeric overflow occurs + */ + public PeriodDuration normalizedYears() { + return withPeriod(period.normalized()); + } + + /** + * Returns a copy of this instance with the days and duration normalized using the standard day of 24 hours. + *

+ * This normalizes the days and duration, leaving the years and months unchanged. + * The result uses a standard day length of 24 hours. + *

+ * This combines the duration seconds with the number of days and shares the total + * seconds between the two fields. For example, a period of + * "2 days and 86401 seconds" will be normalized to "3 days and 1 second". + *

+ * The sign of the days and duration will be the same after normalization. + * For example, a period of "1 day and -172801 seconds" will be normalized to + * "-1 day and -1 second". + *

+ * Note that no normalization is performed on the years or months. + *

+ * This instance is immutable and unaffected by this method call. + * + * @return a {@code PeriodDuration} based on this one with excess duration normalized to days, not null + * @throws ArithmeticException if numeric overflow occurs + */ + public PeriodDuration normalizedStandardDays() { + long totalSecs = period.getDays() * SECONDS_PER_DAY + duration.getSeconds(); + int splitDays = Math.toIntExact(totalSecs / SECONDS_PER_DAY); + long splitSecs = totalSecs % SECONDS_PER_DAY; + if (splitDays == period.getDays() && splitSecs == duration.getSeconds()) { + return this; + } + return PeriodDuration.of(period.withDays(splitDays), duration.withSeconds(splitSecs)); + } + + //----------------------------------------------------------------------- + /** + * Adds this amount to the specified temporal object. + *

+ * This returns a temporal object of the same observable type as the input + * with this amount added. This simply adds the period and duration to the temporal. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param temporal the temporal object to adjust, not null + * @return an object of the same type with the adjustment made, not null + * @throws DateTimeException if unable to add + * @throws UnsupportedTemporalTypeException if the DAYS unit is not supported + * @throws ArithmeticException if numeric overflow occurs + */ + @Override + public Temporal addTo(Temporal temporal) { + return temporal.plus(period).plus(duration); + } + + /** + * Subtracts this amount from the specified temporal object. + *

+ * This returns a temporal object of the same observable type as the input + * with this amount subtracted. This simply subtracts the period and duration from the temporal. + *

+ * This instance is immutable and unaffected by this method call. + * + * @param temporal the temporal object to adjust, not null + * @return an object of the same type with the adjustment made, not null + * @throws DateTimeException if unable to subtract + * @throws UnsupportedTemporalTypeException if the DAYS unit is not supported + * @throws ArithmeticException if numeric overflow occurs + */ + @Override + public Temporal subtractFrom(Temporal temporal) { + return temporal.minus(period).minus(duration); + } + + //----------------------------------------------------------------------- + /** + * Checks if this amount is equal to the specified {@code PeriodDuration}. + *

+ * The comparison is based on the underlying period and duration. + * + * @param otherAmount the other amount, null returns false + * @return true if the other amount is equal to this one + */ + @Override + public boolean equals(Object otherAmount) { + if (this == otherAmount) { + return true; + } + if (otherAmount instanceof PeriodDuration) { + PeriodDuration other = (PeriodDuration) otherAmount; + return this.period.equals(other.period) && this.duration.equals(other.duration); + } + return false; + } + + /** + * A hash code for this amount. + * + * @return a suitable hash code + */ + @Override + public int hashCode() { + return period.hashCode() ^ duration.hashCode(); + } + + //----------------------------------------------------------------------- + /** + * Returns a string representation of the amount. + * This will be in the format 'PnYnMnDTnHnMnS', with sections omitted as necessary. + * An empty amount will return "PT0S". + * + * @return the period in ISO-8601 string format + */ + @Override + public String toString() { + if (period.isZero()) { + return duration.toString(); + } + if (duration.isZero()) { + return period.toString(); + } + return period.toString() + duration.toString().substring(1); + } + +} diff --git a/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDurationFormatter.java b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDurationFormatter.java new file mode 100644 index 000000000..d55f8ae73 --- /dev/null +++ b/li.strolch.utils/src/main/java/li/strolch/utils/time/PeriodDurationFormatter.java @@ -0,0 +1,76 @@ +package li.strolch.utils.time; + +import static li.strolch.utils.I18nStrolchUtilsBundle.i18n; + +import java.time.Duration; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.util.Locale; + +public class PeriodDurationFormatter { + + public static String formatPeriodDuration(Locale locale, PeriodDuration periodDuration) { + + StringBuilder sb = new StringBuilder(); + int u; + + u = (int) periodDuration.get(ChronoUnit.YEARS); + if (u > 0) { + sb.append(u).append(" ").append(i18n(locale, "years")); + periodDuration = periodDuration.minus(Period.ofYears(u)); + + if (periodDuration.isZero()) + return sb.toString(); + sb.append(" "); + } + + u = (int) periodDuration.get(ChronoUnit.MONTHS); + if (u > 0) { + sb.append(u).append(" ").append(i18n(locale, "months")); + periodDuration = periodDuration.minus(Period.ofMonths(u)); + + if (periodDuration.isZero()) + return sb.toString(); + sb.append(" "); + } + + u = (int) periodDuration.get(ChronoUnit.DAYS); + if (u > 0) { + sb.append(u).append(" ").append(i18n(locale, "days")); + periodDuration = periodDuration.minus(Period.ofDays(u)); + + if (periodDuration.isZero()) + return sb.toString(); + sb.append(" "); + } + + Duration duration = periodDuration.getDuration(); + if (duration.isZero()) + return sb.toString(); + + u = (int) duration.toHours(); + if (u > 0) { + sb.append(u).append(" ").append(i18n(locale, "hours")); + duration = duration.minusHours(u); + + if (duration.isZero()) + return sb.toString(); + sb.append(" "); + } + + u = (int) duration.toMinutes(); + if (u > 0) { + sb.append(u).append(" ").append(i18n(locale, "minutes")); + duration = duration.minusMinutes(u); + + if (duration.isZero()) + return sb.toString(); + sb.append(" "); + } + + u = (int) duration.toMillis() / 1000; + sb.append(u).append(" ").append(i18n(locale, "seconds")); + + return sb.toString(); + } +} diff --git a/li.strolch.utils/src/main/resources/strolch-utils.properties b/li.strolch.utils/src/main/resources/strolch-utils.properties new file mode 100644 index 000000000..cae0a05ba --- /dev/null +++ b/li.strolch.utils/src/main/resources/strolch-utils.properties @@ -0,0 +1,18 @@ +day=Day +uom=Unit +Mo=Mo +Mi=Mi +Ev=Ev +Ni=Ni +Tu=Tu +We=We +Th=Th +Fr=Fr +Sa=Sa +Su=Su +days=Days +months=Months +years=Years +minutes=Minutes +hours=Hours +seconds=Seconds diff --git a/li.strolch.utils/src/main/resources/strolch-utils_de.properties b/li.strolch.utils/src/main/resources/strolch-utils_de.properties new file mode 100644 index 000000000..37cc843b3 --- /dev/null +++ b/li.strolch.utils/src/main/resources/strolch-utils_de.properties @@ -0,0 +1,18 @@ +day=Tag +uom=Einheit +Mo=Mo +Mi=Mi +Ev=Ab +Ni=Na +Tu=Di +We=Mi +Th=Do +Fr=Fr +Sa=Sa +Su=So +days=Tage +months=Monate +years=Jahre +minutes=Minuten +hours=Stunden +seconds=Sekunden