From 4069ad9f9cc7af57b9ae823209daf2603fdb3bce Mon Sep 17 00:00:00 2001 From: Vasyl Khrystiuk Date: Thu, 28 Nov 2024 01:54:25 +0200 Subject: [PATCH] fix for https://github.com/bkiers/Liqp/issues/313 --- src/main/java/liqp/TemplateContext.java | 5 + src/main/java/liqp/TemplateParser.java | 26 +++- src/main/java/liqp/filters/Date.java | 3 +- .../liqp/filters/date/BasicDateParser.java | 102 +++++++++++++++ .../filters/date/FuzzyDateDateParser.java | 117 ++++++++++++++++++ src/main/java/liqp/filters/date/Parser.java | 95 ++++---------- .../filters/date/FuzzyDateDateParserTest.java | 27 ++++ 7 files changed, 298 insertions(+), 77 deletions(-) create mode 100644 src/main/java/liqp/filters/date/BasicDateParser.java create mode 100644 src/main/java/liqp/filters/date/FuzzyDateDateParser.java create mode 100644 src/test/java/liqp/filters/date/FuzzyDateDateParserTest.java diff --git a/src/main/java/liqp/TemplateContext.java b/src/main/java/liqp/TemplateContext.java index 33f12dae..ee783a6c 100644 --- a/src/main/java/liqp/TemplateContext.java +++ b/src/main/java/liqp/TemplateContext.java @@ -10,6 +10,7 @@ import liqp.RenderTransformer.ObjectAppender; import liqp.exceptions.ExceededMaxIterationsException; +import liqp.filters.date.BasicDateParser; public class TemplateContext { @@ -67,6 +68,10 @@ public TemplateParser getParser() { return parser; } + public BasicDateParser getDateParser() { + return parser.getDateParser(); + } + public void addError(Exception exception) { this.errors.add(exception); } diff --git a/src/main/java/liqp/TemplateParser.java b/src/main/java/liqp/TemplateParser.java index df078d00..a54a0753 100644 --- a/src/main/java/liqp/TemplateParser.java +++ b/src/main/java/liqp/TemplateParser.java @@ -8,6 +8,8 @@ import liqp.blocks.Block; import liqp.filters.Filter; import liqp.filters.Filters; +import liqp.filters.date.BasicDateParser; +import liqp.filters.date.Parser; import liqp.parser.Flavor; import liqp.parser.LiquidSupport; import liqp.tags.Tag; @@ -36,7 +38,6 @@ public class TemplateParser { public static final TemplateParser DEFAULT_JEKYLL = Flavor.JEKYLL.defaultParser(); - /** * Equivalent of * @@ -91,6 +92,7 @@ public enum ErrorMode { private final int limitMaxSizeRenderedString; private final long limitMaxRenderTimeMillis; private final long limitMaxTemplateSizeBytes; + private final BasicDateParser dateParser; public enum EvaluateMode { LAZY, @@ -158,6 +160,7 @@ public static class Builder { private Long limitMaxRenderTimeMillis = Long.MAX_VALUE; private Long limitMaxTemplateSizeBytes = Long.MAX_VALUE; private NameResolver nameResolver; + private BasicDateParser dateParser; public Builder() { } @@ -188,6 +191,7 @@ public Builder(TemplateParser parser) { this.errorMode = parser.errorMode; this.nameResolver = parser.nameResolver; + this.dateParser = parser.dateParser; } @SuppressWarnings("hiding") @@ -360,6 +364,11 @@ public Builder withErrorMode(ErrorMode errorMode) { return this; } + public Builder withDateParser(BasicDateParser dateParser) { + this.dateParser = dateParser; + return this; + } + @SuppressWarnings("hiding") public TemplateParser build() { @@ -415,8 +424,12 @@ public TemplateParser build() { nameResolver = new LocalFSNameResolver(snippetsFolderName); } + if (dateParser == null) { + dateParser = new Parser(); + } + return new TemplateParser(strictVariables, showExceptionsFromInclude, evaluateMode, renderTransformer, locale, defaultTimeZone, environmentMapConfigurator, errorMode, fl, stripSpacesAroundTags, stripSingleLine, mapper, - allInsertions, finalFilters, evaluateInOutputTag, strictTypedExpressions, liquidStyleInclude, liquidStyleWhere, nameResolver, limitMaxIterations, limitMaxSizeRenderedString, limitMaxRenderTimeMillis, limitMaxTemplateSizeBytes); + allInsertions, finalFilters, evaluateInOutputTag, strictTypedExpressions, liquidStyleInclude, liquidStyleWhere, nameResolver, limitMaxIterations, limitMaxSizeRenderedString, limitMaxRenderTimeMillis, limitMaxTemplateSizeBytes, dateParser); } } @@ -428,7 +441,8 @@ public TemplateParser build() { Consumer> environmentMapConfigurator, ErrorMode errorMode, Flavor flavor, boolean stripSpacesAroundTags, boolean stripSingleLine, ObjectMapper mapper, Insertions insertions, Filters filters, boolean evaluateInOutputTag, boolean strictTypedExpressions, - boolean liquidStyleInclude, Boolean liquidStyleWhere, NameResolver nameResolver, int maxIterations, int maxSizeRenderedString, long maxRenderTimeMillis, long maxTemplateSizeBytes) { + boolean liquidStyleInclude, Boolean liquidStyleWhere, NameResolver nameResolver, int maxIterations, int maxSizeRenderedString, long maxRenderTimeMillis, long maxTemplateSizeBytes, + BasicDateParser dateParser) { this.flavor = flavor; this.stripSpacesAroundTags = stripSpacesAroundTags; this.stripSingleLine = stripSingleLine; @@ -455,6 +469,7 @@ public TemplateParser build() { this.limitMaxSizeRenderedString = maxSizeRenderedString; this.limitMaxRenderTimeMillis = maxRenderTimeMillis; this.limitMaxTemplateSizeBytes = maxTemplateSizeBytes; + this.dateParser = dateParser; } public Template parse(Path path) throws IOException { @@ -529,6 +544,11 @@ public Consumer> getEnvironmentMapConfigurator() { return environmentMapConfigurator; } + + public BasicDateParser getDateParser() { + return dateParser; + } + private Path getLocationFromInputStream(InputStream input) { try { if (input instanceof FileInputStream) { diff --git a/src/main/java/liqp/filters/Date.java b/src/main/java/liqp/filters/Date.java index 601822d8..f398799f 100644 --- a/src/main/java/liqp/filters/Date.java +++ b/src/main/java/liqp/filters/Date.java @@ -2,6 +2,7 @@ import liqp.LValue; import liqp.TemplateContext; +import liqp.filters.date.BasicDateParser; import liqp.filters.date.CustomDateFormatRegistry; import liqp.filters.date.CustomDateFormatSupport; import liqp.filters.date.Parser; @@ -46,7 +47,7 @@ public Object apply(Object value, TemplateContext context, Object... params) { // No need to divide this by 1000, the param is expected to be in seconds already! compatibleDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(super.asNumber(value).longValue() * 1000), context.getParser().defaultTimeZone); } else { - compatibleDate = Parser.parse(valAsString, locale, context.getParser().defaultTimeZone); + compatibleDate = context.getDateParser().parse(valAsString, locale, context.getParser().defaultTimeZone); } if (compatibleDate == null) { return value; diff --git a/src/main/java/liqp/filters/date/BasicDateParser.java b/src/main/java/liqp/filters/date/BasicDateParser.java new file mode 100644 index 00000000..f67d8b92 --- /dev/null +++ b/src/main/java/liqp/filters/date/BasicDateParser.java @@ -0,0 +1,102 @@ +package liqp.filters.date; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalField; +import java.time.temporal.TemporalQueries; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static java.time.temporal.ChronoField.*; + +public abstract class BasicDateParser { + + private final List cachedPatterns = new ArrayList<>(); + + protected BasicDateParser() { + + } + + protected BasicDateParser(List patterns) { + cachedPatterns.addAll(patterns); + } + + protected void storePattern(String pattern) { + cachedPatterns.add(pattern); + } + + public abstract ZonedDateTime parse(String valAsString, Locale locale, ZoneId timeZone); + + protected ZonedDateTime parseUsingCachedPatterns(String str, Locale locale, ZoneId defaultZone) { + for(String pattern : cachedPatterns) { + try { + TemporalAccessor temporalAccessor = parseUsingPattern(str, pattern, locale); + return getZonedDateTimeFromTemporalAccessor(temporalAccessor, defaultZone); + } catch (Exception e) { + // ignore + } + } + // Could not parse the string into a meaningful date, return null. + return null; + } + + protected TemporalAccessor parseUsingPattern(String normalized, String pattern, Locale locale) { + DateTimeFormatter timeFormatter = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern(pattern) + .toFormatter(locale); + + return timeFormatter.parse(normalized); + } + + + /** + * Follow ruby rules: if some datetime part is missing, + * the default is taken from `now` with default zone + */ + public static ZonedDateTime getZonedDateTimeFromTemporalAccessor(TemporalAccessor temporal, ZoneId defaultZone) { + if (temporal == null) { + return ZonedDateTime.now(defaultZone); + } + if (temporal instanceof ZonedDateTime) { + return (ZonedDateTime) temporal; + } + if (temporal instanceof Instant) { + return ZonedDateTime.ofInstant((Instant) temporal, defaultZone); + } + + ZoneId zoneId = temporal.query(TemporalQueries.zone()); + if (zoneId == null) { + LocalDate date = temporal.query(TemporalQueries.localDate()); + LocalTime time = temporal.query(TemporalQueries.localTime()); + + if (date == null) { + date = LocalDate.now(defaultZone); + } + if (time == null) { + time = LocalTime.now(defaultZone); + } + return ZonedDateTime.of(date, time, defaultZone); + } else { + LocalDateTime now = LocalDateTime.now(zoneId); + TemporalField[] copyThese = new TemporalField[]{ + YEAR, + MONTH_OF_YEAR, + DAY_OF_MONTH, + HOUR_OF_DAY, + MINUTE_OF_HOUR, + SECOND_OF_MINUTE, + NANO_OF_SECOND + }; + for (TemporalField tf: copyThese) { + if (temporal.isSupported(tf)) { + now = now.with(tf, temporal.get(tf)); + } + } + return now.atZone(zoneId); + } + } +} diff --git a/src/main/java/liqp/filters/date/FuzzyDateDateParser.java b/src/main/java/liqp/filters/date/FuzzyDateDateParser.java new file mode 100644 index 00000000..714f219a --- /dev/null +++ b/src/main/java/liqp/filters/date/FuzzyDateDateParser.java @@ -0,0 +1,117 @@ +package liqp.filters.date; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class FuzzyDateDateParser extends BasicDateParser { + + @Override + public ZonedDateTime parse(String valAsString, Locale locale, ZoneId defaultZone) { + String normalized = valAsString.toLowerCase(); + ZonedDateTime zonedDateTime = parseUsingCachedPatterns(normalized, locale, defaultZone); + if (zonedDateTime != null) { + return zonedDateTime; + } + + List parts = new ArrayList<>(); + // we start as one big single unparsed part + DateParseContext ctx = new DateParseContext(); + parts.add(new UnparsedPart(0, normalized.length(), normalized)); + + while (haveUnparsed(parts)) { + parts = parsePart(parts, ctx); + } + + String pattern = reconstructPattern(parts); + + TemporalAccessor temporalAccessor = parseUsingPattern(normalized, pattern, locale); + if (temporalAccessor == null) { + return null; + } + storePattern(pattern); + return getZonedDateTimeFromTemporalAccessor(temporalAccessor, defaultZone); + } + + + private String reconstructPattern(List parts) { + return null; + } + + static class DateParseContext { + + } + + private List parsePart(List parts, DateParseContext ctx) { + return new ArrayList<>(); + } + + private boolean haveUnparsed(List parts) { + return parts.stream().anyMatch(p -> p.state() == PartState.UNPARSED); + } + + private PartItem getPart(String valAsString) { + return null; + } + enum PartState { + UNPARSED, + PARSED, + KNOWN_CONSTANT, + UNRECOGNIZED + } + interface Part { + int start(); // before symbol + int end(); // after symbol + PartState state(); + } + + static class UnparsedPart implements Part { + final int start; + final int end; + UnparsedPart(int start, int end, String value) { + this.start = start; + this.end = end; + } + @Override + public int start() { + return start; + } + @Override + public int end() { + return end; + } + @Override + public PartState state() { + return PartState.UNPARSED; + } + } + + static class PartItem { + final PartKind kind; + final String pattern; + final int start; + final int end; + PartItem(PartKind kind, String pattern, int start, int end) { + this.kind = kind; + this.pattern = pattern; + this.start = start; + this.end = end; + } + } + enum PartKind { + CONSTANT, + YEAR, + MONTH, + DAY, + HOUR, + MINUTE, + SECOND, + MILLISECOND, + MICROSECOND, + NANOSECOND + } + +} diff --git a/src/main/java/liqp/filters/date/Parser.java b/src/main/java/liqp/filters/date/Parser.java index fb424643..2f01cfbe 100644 --- a/src/main/java/liqp/filters/date/Parser.java +++ b/src/main/java/liqp/filters/date/Parser.java @@ -6,13 +6,11 @@ import java.time.temporal.TemporalAccessor; import java.time.temporal.TemporalField; import java.time.temporal.TemporalQueries; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; +import java.util.*; import static java.time.temporal.ChronoField.*; -public class Parser { +public class Parser extends BasicDateParser { /** * In case if anyone interesting about full set @@ -30,7 +28,18 @@ public class Parser { // Since Liquid supports dates like `March 1st`, this list will // hold strings that will be removed from the input string. - private static final String[] toBeRemoved = new String[] { "st", "nd", "rd", "th" }; + private static final Map toBeReplaced = new HashMap() {{ + this.put("1st", "1"); + this.put("2nd", "2"); + this.put("3rd", "3"); + this.put("4th", "4"); + this.put("5th", "5"); + this.put("6th", "6"); + this.put("7th", "7"); + this.put("8th", "8"); + this.put("9th", "9"); + this.put("0th", "0"); + }}; static { @@ -147,77 +156,17 @@ public class Parser { datePatterns.add("H:mm:ss"); } - public static ZonedDateTime parse(String str, Locale locale, ZoneId defaultZone) { - String normalized = str.toLowerCase(); - - for(String value : toBeRemoved) { - normalized = normalized.replace(value, ""); - } - - for(String pattern : datePatterns) { - try { - - DateTimeFormatter timeFormatter = new DateTimeFormatterBuilder() - .parseCaseInsensitive() - .appendPattern(pattern) - .toFormatter(locale); - - TemporalAccessor temporalAccessor = timeFormatter.parse(normalized); - return getZonedDateTimeFromTemporalAccessor(temporalAccessor, defaultZone); - } catch (Exception e) { - // ignore - } - } - - // Could not parse the string into a meaningful date, return null. - return null; + public Parser() { + super(datePatterns); } - /** - * Follow ruby rules: if some datetime part is missing, - * the default is taken from `now` with default zone - */ - public static ZonedDateTime getZonedDateTimeFromTemporalAccessor(TemporalAccessor temporal, ZoneId defaultZone) { - if (temporal == null) { - return ZonedDateTime.now(defaultZone); - } - if (temporal instanceof ZonedDateTime) { - return (ZonedDateTime) temporal; - } - if (temporal instanceof Instant) { - return ZonedDateTime.ofInstant((Instant) temporal, defaultZone); - } - - ZoneId zoneId = temporal.query(TemporalQueries.zone()); - if (zoneId == null) { - LocalDate date = temporal.query(TemporalQueries.localDate()); - LocalTime time = temporal.query(TemporalQueries.localTime()); - - if (date == null) { - date = LocalDate.now(defaultZone); - } - if (time == null) { - time = LocalTime.now(defaultZone); - } - return ZonedDateTime.of(date, time, defaultZone); - } else { - LocalDateTime now = LocalDateTime.now(zoneId); - TemporalField[] copyThese = new TemporalField[]{ - YEAR, - MONTH_OF_YEAR, - DAY_OF_MONTH, - HOUR_OF_DAY, - MINUTE_OF_HOUR, - SECOND_OF_MINUTE, - NANO_OF_SECOND - }; - for (TemporalField tf: copyThese) { - if (temporal.isSupported(tf)) { - now = now.with(tf, temporal.get(tf)); - } - } - return now.atZone(zoneId); + public ZonedDateTime parse(String str, Locale locale, ZoneId defaultZone) { + String normalized = str.toLowerCase(); + for(Map.Entry kv : toBeReplaced.entrySet()) { + normalized = normalized.replace(kv.getKey(), kv.getValue()); } + return parseUsingCachedPatterns(normalized, locale, defaultZone); } + } diff --git a/src/test/java/liqp/filters/date/FuzzyDateDateParserTest.java b/src/test/java/liqp/filters/date/FuzzyDateDateParserTest.java new file mode 100644 index 00000000..7939a45a --- /dev/null +++ b/src/test/java/liqp/filters/date/FuzzyDateDateParserTest.java @@ -0,0 +1,27 @@ +package liqp.filters.date; + +import org.junit.Test; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Locale; + +import static org.junit.Assert.*; + +public class FuzzyDateDateParserTest { + + private FuzzyDateDateParser parser = new FuzzyDateDateParser(); +// +// @Test +// public void test() { +// // given +// String input = "2020-01-01"; +// +// // when +// ZonedDateTime parse = parser.parse(input, Locale.ROOT, ZoneOffset.UTC); +// +// // then +// assertEquals("2020-01-01T00:00:00Z", parse.toString()); +// } + +} \ No newline at end of file