package com.supaham.commons.utils; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Preconditions; import com.supaham.commons.exceptions.DurationParseException; import com.supaham.commons.exceptions.TimeParseException; import java.math.BigInteger; import java.time.LocalTime; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nonnull; /** * Utility methods for working with time. This class contains methods such as {@link #elapsed(long, * long)} and more. * * @since 0.1 */ public class TimeUtils { public static final int EPOCH_YEAR = 1970; /** * Hours per day. */ public static final int HOURS_PER_DAY = 24; /** * Minutes per hour. */ public static final int MINUTES_PER_HOUR = 60; /** * Seconds per minute. */ public static final int SECONDS_PER_MINUTE = 60; /** * Seconds per hour. */ public static final int SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; /** * Seconds per day. */ public static final int SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY; public static final long NANOS_PER_SECOND = 1000_000_000L; public static final BigInteger BI_NANOS_PER_SECOND = BigInteger.valueOf(NANOS_PER_SECOND); public static final Pattern PATTERN = Pattern.compile("(-?\\d*\\.?\\d*)(ms|[dhms])"); public static final Pattern TIME_PATTERN = Pattern .compile("(?<hours>\\d?\\d)(?::(?<minutes>\\d\\d))?(?::(?<seconds>\\d\\d))?\\s*(?<ampm>[aApP]\\.?[mM]\\.?)*"); /** * Checks whether a certain amount of milliseconds have elapsed a given time in milliseconds. * This method simply subtracts the given duration from the current time in milliseconds and * checks if the result is greater than the required amount of milliseconds. * <pre> * TimeUtils.elapsed(now - 1000, 1000) = true * TimeUtils.elapsed(now - 999, 1000) = true * TimeUtils.elapsed(now, 1000) = false * </pre> * * @param duration the real time milliseconds. * @param required the amount of milliseconds that is required to have passed the {@code * duration} in order to succeed * * @return whether the {@code required} milliseconds have elapsed the {@code duration} */ public static boolean elapsed(long duration, long required) { return System.currentTimeMillis() - duration >= required; } /** * Gets the needed duration format based on the given seconds. * <pre> * TimeUtils.getNeededDurationFormat(60) = "ss"; * TimeUtils.getNeededDurationFormat(90) = "mm:ss"; * TimeUtils.getNeededDurationFormat(4000) = "HH:mm:ss"; * TimeUtils.getNeededDurationFormat(100000) = "dd:HH:mm:ss"; * </pre> * * @param seconds seconds to base duration format off * * @return the duration format */ public static String getNeededDurationFormat(int seconds) { Preconditions.checkArgument(seconds > 0, "seconds must be larger than 0."); String format = ""; if (seconds > 86400) { format = "dd"; // more than 24 hours } if (seconds > 3600) { format += (!format.isEmpty() ? ":" : "") + "HH"; // more than 60 minutes } if (seconds > 60) { format += (!format.isEmpty() ? ":" : "") + "mm"; // more than 60 seconds } format += (!format.isEmpty() ? ":" : "") + "ss"; return format; } /** * Parses a {@link CharSequence} into a {@code long} duration represented as seconds. * The pattern used to parse the duration is {@link TimeUtils#PATTERN}. In short, 1d2h3m4s is * valid, whereas 1x is not. * <pre> * DurationUtils.parseDuration("1s") is <b>valid</b> * DurationUtils.parseDuration("1m1s") is <b>valid</b> * DurationUtils.parseDuration("1h1m1s") is <b>valid</b> * DurationUtils.parseDuration("1d1h1m1s") is <b>valid</b> * DurationUtils.parseDuration("1d1h1m1s1x") is <b>valid</b> * DurationUtils.parseDuration("1x") is <b>invalid</b> * </pre> * * @param text text to parse * * @return the duration * * @throws DurationParseException thrown if the text failed to parse */ public static long parseDuration(@Nonnull CharSequence text) throws DurationParseException { return parseDurationMs(text) / 1000; } /** * Parses a {@link CharSequence} into a {@code long} duration represented as milliseconds. * The pattern used to parse the duration is {@link TimeUtils#PATTERN}. In short, 1d2h3m4s5ms is * valid, whereas 1x is not. * <pre> * DurationUtils.parseDuration("1s1ms") is <b>valid</b> * DurationUtils.parseDuration("1m1s2ms") is <b>valid</b> * DurationUtils.parseDuration("1h1m1s3ms") is <b>valid</b> * DurationUtils.parseDuration("1d1h1m1s4ms") is <b>valid</b> * DurationUtils.parseDuration("1d1h1m1s1x5ms") is <b>valid</b> * DurationUtils.parseDuration("1x") is <b>invalid</b> * </pre> * * @param text text to parse * * @return the milliseconds duration * * @throws DurationParseException thrown if the text failed to parse */ public static long parseDurationMs(@Nonnull CharSequence text) throws DurationParseException { checkNotNull(text, "text cannot be null."); checkArgument(text.length() > 0, "text cannot be empty."); if (text.equals("0")) { return 0; } Matcher matcher = PATTERN.matcher(text); long sum = 0; boolean foundUnit = false; while (matcher.find()) { String d = matcher.group(1); String u = StringUtils.lowerCase(matcher.group(2)); int multiplier; String unitString; foundUnit = true; switch (u) { case "d": multiplier = TimeUtils.SECONDS_PER_DAY * 1000; unitString = "days"; break; case "h": multiplier = TimeUtils.SECONDS_PER_HOUR * 1000; unitString = "hours"; break; case "m": multiplier = TimeUtils.SECONDS_PER_MINUTE * 1000; unitString = "minutes"; break; case "s": multiplier = 1000; unitString = "seconds"; break; case "ms": multiplier = 1; unitString = "milliseconds"; break; default: continue; } try { sum += parseNumber(d, multiplier, unitString); } catch (ArithmeticException ex) { throw (DurationParseException) new DurationParseException( "Text cannot be parsed as milliseconds: overflow").initCause(ex); } } if (sum == 0 && !foundUnit) { throw new DurationParseException("Text cannot be parsed as milliseconds " + text); } return sum; } private static long parseNumber(String parsed, int multiplier, String errorText) { // regex limits to [-+]?[0-9]+ if (parsed == null) { return 0; } try { double val = Double.parseDouble(parsed); return (long) (val * multiplier); } catch (NumberFormatException | ArithmeticException ex) { throw (DurationParseException) new DurationParseException("Text cannot be parsed to a Duration: " + errorText) .initCause(ex); } } /** * Converts a {@code long} duration of seconds into a {@link String}. <br /> The following * examples are demonstrated for a seconds with 3725 seconds: * <pre> * DurationUtils.toString(seconds, true) = 1h2m5s * DurationUtils.toString(seconds, false) = 1 hour 2 minutes 5 seconds * </pre> * * @param seconds seconds to convert * @param simple whether the string should be simple or pretty. If true, simple, the string can * be passed later to {@link #parseDuration(CharSequence)} for parsing into a long-duration * once again. * * @return the string of the {@code seconds} */ public static String toString(long seconds, boolean simple) { long days = seconds / TimeUtils.SECONDS_PER_DAY; long hours = (seconds % TimeUtils.SECONDS_PER_DAY) / TimeUtils.SECONDS_PER_HOUR; int minutes = (int) ((seconds % TimeUtils.SECONDS_PER_HOUR) / TimeUtils.SECONDS_PER_MINUTE); int secs = (int) (seconds % TimeUtils.SECONDS_PER_MINUTE); StringBuilder buf = new StringBuilder(24); if (days != 0) { buf.append(days).append(simple ? "d" : " day" + (days != 1 && days != -1 ? "s" : "")); } if (hours != 0) { if (!simple && buf.length() > 0) { buf.append(" "); } buf.append(hours).append(simple ? "h" : " hour" + (hours != 1 && hours != -1 ? "s" : "")); } if (minutes != 0) { if (!simple && buf.length() > 0) { buf.append(" "); } buf.append(minutes).append(simple ? "m" : " minute" + (minutes != 1 && minutes != -1 ? "s" : "")); } if (secs != 0) { if (!simple && buf.length() > 0) { buf.append(" "); } buf.append(secs).append(simple ? "s" : " second" + (secs != 1 && secs != -1 ? "s" : "")); } return buf.toString(); } /** * Parses a {@link LocalTime} in the form of a string. The {@link Pattern} used to match the {@code timeString} is * {@link #TIME_PATTERN}. A combination of hours, minutes, and seconds may be present to form a full 0-23 hour point * in time. This method supports <b>12-hour time format</b>, where a string can be 12AM, 12:00PM, etc. * <pre><code> * TimeUtils.parse(null) = {@link NullPointerException} * TimeUtils.parse("") = {@link IllegalArgumentException} * TimeUtils.parse("asd") = {@link TimeParseException} * TimeUtils.parse("24") = {@link TimeParseException} * TimeUtils.parse("23") = {@link TimeParseException} * TimeUtils.parse("0") = LocalTime{hours=0} * TimeUtils.parse("1") = LocalTime{hours=1} * TimeUtils.parse("01:00") = LocalTime{hours=1} * TimeUtils.parse("1:00") = LocalTime{hours=1} * TimeUtils.parse("01:23") = LocalTime{hours=1,minutes=23} * TimeUtils.parse("01:23:45") = LocalTime{hours=1,minutes=23,seconds=45} * TimeUtils.parse("13:23:45") = LocalTime{hours=13,minutes=23,seconds=45} * TimeUtils.parse("24:23:45") = {@link TimeParseException} * * TimeUtils.parse("12AM") = LocalTime{hours=0} * TimeUtils.parse("00AM") = {@link TimeParseException} * TimeUtils.parse("1AM") = LocalTime{hours=1} * TimeUtils.parse("01AM") = LocalTime{hours=1} * TimeUtils.parse("01:23AM") = LocalTime{hours=1,minutes=23} * TimeUtils.parse("01:23:45AM") = LocalTime{hours=1,minutes=23,seconds=45} * TimeUtils.parse("12A.M.") = LocalTime{hours=0} * TimeUtils.parse("12am") = LocalTime{hours=0} * TimeUtils.parse("12a.m.") = LocalTime{hours=0} * * TimeUtils.parse("12PM") = LocalTime{hours=12} * TimeUtils.parse("00PM") = {@link TimeParseException} * TimeUtils.parse("1PM") = LocalTime{hours=1} * TimeUtils.parse("01PM") = LocalTime{hours=1} * TimeUtils.parse("01:23PM") = LocalTime{hours=1,minutes=23} * TimeUtils.parse("01:23:45PM") = LocalTime{hours=1,minutes=23,seconds=45} * TimeUtils.parse("12P.M.") = LocalTime{hours=12} * TimeUtils.parse("12pm") = LocalTime{hours=12} * TimeUtils.parse("12p.m.") = LocalTime{hours=12} * </code></pre> * * @param timeString string in time format * * @return parsed {@code timeString} in a LocalTime instance * * @throws TimeParseException thrown if the timeString is in an invalid format or includes invalid time units */ @Nonnull public static LocalTime parseTime(@Nonnull String timeString) throws TimeParseException { StringUtils.checkNotNullOrEmpty(timeString, "time string"); Matcher matcher = TIME_PATTERN.matcher(timeString); checkTime(timeString, matcher.matches(), timeString + " is not a valid time."); int hours = Integer.parseInt(matcher.group("hours")); int minutes = 0; int seconds = 0; Boolean isAM = null; try { minutes = Integer.parseInt(matcher.group("minutes")); } catch (IllegalArgumentException ignored) { // ignores NumberFormatException as well } try { seconds = Integer.parseInt(matcher.group("seconds")); } catch (IllegalArgumentException ignored) { // ignores NumberFormatException as well } if (matcher.group("ampm") != null) { isAM = matcher.group("ampm").toLowerCase() .replaceAll("\\.", "") // 12-hour time might be A.M or P.M. .equals("am"); } checkTime(timeString, hours >= 0 && hours <= 23, "hours cannot be less than 0 or greater than 23."); checkTime(timeString, minutes >= 0 && minutes <= 60, "minutes cannot be less than 0 or greater than 60."); checkTime(timeString, seconds >= 0 && seconds <= 60, "seconds cannot be less than 0 or greater than 60."); // 12-hour format if (isAM != null) { checkTime(timeString, hours >= 1 && hours <= 12, "hours cannot be less than 1 or greater than 12 in 12-hour format."); // And the problem universally. // Explained here: https://en.wikipedia.org/wiki/12-hour_clock#Confusion_at_noon_and_midnight if (hours == 12) { hours = 0; } if (!isAM) { // PM hours += 12; } } return LocalTime.of(hours, minutes, seconds); } private static void checkTime(String timeString, boolean b, String message) throws TimeParseException { if (!b) { throw new TimeParseException(timeString, message); } } public static String localTimeToString(LocalTime localTime) { return localTime == null ? null : localTime.toString(); } private TimeUtils() { throw new AssertionError("Did I say you can instantiate me? HUH?"); } }