From be58ff57cdbbfcfa456d1434fd6e3009da9f918a Mon Sep 17 00:00:00 2001 From: Lexi Larkin Date: Sun, 24 Dec 2023 02:50:58 -0500 Subject: [PATCH] feat: duration parsing --- .../converter/ConverterRegistry.java | 2 + .../converter/impl/DurationConverter.java | 149 ++++++++++++++++++ .../impl/ZonedDateTimeConverter.java | 31 ++-- src/main/resources/langs/quasicord/en.yaml | 2 + 4 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 src/main/java/dev/qixils/quasicord/converter/impl/DurationConverter.java diff --git a/src/main/java/dev/qixils/quasicord/converter/ConverterRegistry.java b/src/main/java/dev/qixils/quasicord/converter/ConverterRegistry.java index ee9f53b..6ddaaaf 100644 --- a/src/main/java/dev/qixils/quasicord/converter/ConverterRegistry.java +++ b/src/main/java/dev/qixils/quasicord/converter/ConverterRegistry.java @@ -7,6 +7,7 @@ package dev.qixils.quasicord.converter; import dev.qixils.quasicord.Quasicord; +import dev.qixils.quasicord.converter.impl.DurationConverter; import dev.qixils.quasicord.converter.impl.LocaleConverter; import dev.qixils.quasicord.converter.impl.ZoneIdConverter; import dev.qixils.quasicord.converter.impl.ZonedDateTimeConverter; @@ -50,6 +51,7 @@ public ConverterRegistry(@NonNull Quasicord library) { register(new ConverterImpl<>(Locale.class, DiscordLocale.class, (it, locale) -> DiscordLocale.from(locale))); register(new ConverterImpl<>(DiscordLocale.class, Locale.class, (it, locale) -> locale.toLocale())); register(new ZoneIdConverter()); + register(new DurationConverter(library)); // channels register(ConverterImpl.channel(TextChannel.class)); register(ConverterImpl.channel(PrivateChannel.class)); diff --git a/src/main/java/dev/qixils/quasicord/converter/impl/DurationConverter.java b/src/main/java/dev/qixils/quasicord/converter/impl/DurationConverter.java new file mode 100644 index 0000000..b4410cf --- /dev/null +++ b/src/main/java/dev/qixils/quasicord/converter/impl/DurationConverter.java @@ -0,0 +1,149 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.qixils.quasicord.converter.impl; + +import dev.qixils.quasicord.Key; +import dev.qixils.quasicord.Quasicord; +import dev.qixils.quasicord.converter.Converter; +import dev.qixils.quasicord.error.UserError; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.dv8tion.jda.api.interactions.Interaction; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.time.Duration; +import java.util.*; +import java.util.function.BiFunction; +import java.util.regex.Pattern; + +@Getter +@RequiredArgsConstructor +public class DurationConverter implements Converter { + private final @NonNull Class interactionClass = Interaction.class; + private final @NonNull Class inputClass = String.class; + private final @NonNull Class outputClass = Duration.class; + private final @NonNull Quasicord library; + + private static final @NonNull Set RELATIVE_TIME_IGNORED_TOKENS = Set.of("in"); + private static final @NonNull Pattern RELATIVE_TIME_PATTERN = Pattern.compile("^\\d+[A-Za-z]+"); + + @Override + public @NonNull Duration convert(@NonNull Interaction interaction, @NonNull String input) { + List arguments = new ArrayList<>(Arrays.asList(input.split(" "))); + arguments.removeIf(s -> RELATIVE_TIME_IGNORED_TOKENS.contains(s.toLowerCase(Locale.ENGLISH))); + String strippedInput = String.join("", arguments); + + if (!RELATIVE_TIME_PATTERN.matcher(strippedInput).matches()) { + throw new UserError(Key.library("exception.duration.regex")); + } + + boolean negative = false; + if (arguments.get(0).startsWith("-")) { + arguments.set(0, arguments.get(0).substring(1)); + negative = true; + } + if (arguments.get(arguments.size() - 1).equalsIgnoreCase("ago")) { + arguments.remove(arguments.size() - 1); + negative = !negative; + } + + // vars for parsing the duration + Duration duration = Duration.ZERO; + StringBuilder nextToken = new StringBuilder(); + boolean buildingAmount = true; + String amountToken = null; + String unitToken; + // vars for parsing the terms/tokens used fom the arg list + StringBuilder term = new StringBuilder(); + int termIdx = 0; + int parsedTermIdx = 0; + + for (char chr : strippedInput.toCharArray()) { + term.append(chr); + if (term.toString().equals(arguments.get(termIdx))) { + term = new StringBuilder(); + ++termIdx; + } + if (Character.isWhitespace(chr)) { + continue; + } + + boolean isDigit = Character.isDigit(chr); + if (isDigit && !buildingAmount) { + // finished parsing unit of time (and duration of time) + buildingAmount = true; + unitToken = nextToken.toString(); + nextToken = new StringBuilder(); + // now attempt to find the corresponding RelativeTimeUnit + RelativeTimeUnit unit = RelativeTimeUnit.of(unitToken); + if (unit == null) { + throw new UserError(Key.library("exception.duration.unit"), unitToken); + } + // add relative time to current time object + duration = unit.addTimeTo(duration, Long.parseLong(amountToken)); // theoretically this shouldn't error + parsedTermIdx = termIdx; + // reset variables + amountToken = null; + } else if (!isDigit && buildingAmount) { + // finished parsing duration of time, save it & reset the builder + buildingAmount = false; + amountToken = nextToken.toString(); + nextToken = new StringBuilder(); + } + nextToken.append(chr); + } + + if (negative) + duration = Duration.ZERO.minus(duration); + + while (parsedTermIdx > 0) { + arguments.remove(0); + parsedTermIdx--; + } + + return duration; + } + + private enum RelativeTimeUnit { + SECOND(Duration::plusSeconds, "s", "sec", "secs", "second", "seconds"), + MINUTE(Duration::plusMinutes, "m", "min", "mins", "minute", "minutes"), + HOUR(Duration::plusHours, "h", "hr", "hrs", "hour", "hours"), + DAY(Duration::plusDays, "d", "day", "days"), + WEEK((duration, time) -> duration.plusDays(time * 7), "w", "week", "weeks"); + + private final @NonNull BiFunction<@NonNull Duration, @NonNull Long, @NonNull Duration> addTimeFunction; + private final String @NonNull [] tokens; // TODO: i18n plurals ? + + RelativeTimeUnit( + @NonNull BiFunction<@NonNull Duration, @NonNull Long, @NonNull Duration> addTimeFunction, + String @NonNull ... tokens + ) { + this.addTimeFunction = addTimeFunction; + this.tokens = tokens; + } + + public boolean matches(@NonNull String input) { + for (String token : tokens) { + if (token.equalsIgnoreCase(input)) + return true; + } + return false; + } + + public @NonNull Duration addTimeTo(@NonNull Duration originalTime, long duration) { + return addTimeFunction.apply(originalTime, duration); + } + + public static RelativeTimeUnit of(@NonNull String token) { + for (RelativeTimeUnit unit : values()) { + if (unit.matches(token)) + return unit; + } + return null; + } + } +} diff --git a/src/main/java/dev/qixils/quasicord/converter/impl/ZonedDateTimeConverter.java b/src/main/java/dev/qixils/quasicord/converter/impl/ZonedDateTimeConverter.java index 2c77125..813d1a6 100644 --- a/src/main/java/dev/qixils/quasicord/converter/impl/ZonedDateTimeConverter.java +++ b/src/main/java/dev/qixils/quasicord/converter/impl/ZonedDateTimeConverter.java @@ -11,10 +11,10 @@ import dev.qixils.quasicord.db.collection.TimeZoneConfig; import dev.qixils.quasicord.error.UserError; import lombok.Getter; -import lombok.RequiredArgsConstructor; import net.dv8tion.jda.api.interactions.Interaction; import org.checkerframework.checker.nullness.qual.NonNull; +import java.time.Duration; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -24,25 +24,39 @@ import static dev.qixils.quasicord.Key.library; @Getter -@RequiredArgsConstructor public class ZonedDateTimeConverter implements Converter { private final @NonNull Class interactionClass = Interaction.class; private final @NonNull Class inputClass = String.class; private final @NonNull Class outputClass = ZonedDateTime.class; private final @NonNull Quasicord library; + private final @NonNull DurationConverter durationConverter; private static final Pattern[] DATE_PATTERNS = { - Pattern.compile("(?\\d{4})-(?\\d{1,2})-(?\\d{1,2})"), - Pattern.compile("(?\\d{1,2})/(?\\d{1,2})/(?\\d{4})"), - Pattern.compile("(?\\p{L}{3,}) (?\\d{1,2}) (?\\d{4})", Pattern.UNICODE_CHARACTER_CLASS), - Pattern.compile("(?\\d{1,2}) (?\\p{L}{3,}) (?\\d{4})", Pattern.UNICODE_CHARACTER_CLASS), + Pattern.compile("(?\\d{4})-(?\\d{1,2})-(?\\d{1,2})"), + Pattern.compile("(?\\d{1,2})/(?\\d{1,2})/(?\\d{4})"), + // TODO month name parse + //Pattern.compile("(?\\p{L}{3,}) (?\\d{1,2}) (?\\d{4})", Pattern.UNICODE_CHARACTER_CLASS), + //Pattern.compile("(?\\d{1,2}) (?\\p{L}{3,}) (?\\d{4})", Pattern.UNICODE_CHARACTER_CLASS), }; private static final Pattern TIME_PATTERN = Pattern.compile("(?\\d{1,2})(?::(?\\d{2})(?::(?\\d{2})(?:\\.(?\\d{1,9}))?)?)?(?: ?(?[Aa]|[Pp])\\.?[Mm]\\.?)?"); + public ZonedDateTimeConverter(@NonNull Quasicord library) { + this.library = library; + durationConverter = new DurationConverter(library); + } + @Override public @NonNull ZonedDateTime convert(@NonNull Interaction interaction, @NonNull String input) { TimeZoneConfig config = library.getDatabaseManager().getById(interaction.getUser().getIdLong(), TimeZoneConfig.class).block(); ZoneId zone = config == null ? ZoneOffset.UTC : config.getTimeZone(); + ZonedDateTime now = ZonedDateTime.now(zone); + + try { + Duration add = durationConverter.convert(interaction, input); + return now.plus(add); + } catch (Exception ignored) { + } + int year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0, nanos = 0; char meridiem = 'x'; @@ -53,6 +67,7 @@ public class ZonedDateTimeConverter implements Converter continue; year = Integer.parseInt(dateMatcher.group("year")); if (dateMatcher.group("month") != null) { + // TODO month name parse month = Integer.parseInt(dateMatcher.group("month")); day = Integer.parseInt(dateMatcher.group("day")); } else { @@ -63,14 +78,12 @@ public class ZonedDateTimeConverter implements Converter } // error if date is invalid - var now = ZonedDateTime.now(zone); boolean autoDate = month == 0 && day == 0 && year == 0; if (autoDate) { year = now.getYear(); month = now.getMonthValue(); day = now.getDayOfMonth(); - } - else if (month < 1 || day < 1 || year == 0 || month > 12 || day > 31) + } else if (month < 1 || day < 1 || year == 0 || month > 12 || day > 31) throw new UserError(library("exception.invalid_date")); // get time diff --git a/src/main/resources/langs/quasicord/en.yaml b/src/main/resources/langs/quasicord/en.yaml index 68b3eef..0e17277 100644 --- a/src/main/resources/langs/quasicord/en.yaml +++ b/src/main/resources/langs/quasicord/en.yaml @@ -16,6 +16,8 @@ en: "exception.command_error": An internal error occurred while executing this command. The issue has been reported to the developers. "exception.invalid_locale": Could not find a language by the name of `{0}` "exception.invalid_timezone": Could not find a timezone by the ID of `{0}` + "exception.duration.regex": Duration should contain only numbers, letters, and spaces + "exception.duration.unit": Could not find a unit of time by the name `{0}` "snowflake_confirm": "{0}: Did you mean {1}?" "timezone_display": "{0} ({1})"