diff --git a/src/foam/parse/DateGrammar.js b/src/foam/parse/DateGrammar.js index 6cc5f7bd55..e2aefb800c 100644 --- a/src/foam/parse/DateGrammar.js +++ b/src/foam/parse/DateGrammar.js @@ -16,6 +16,7 @@ foam.CLASS({ - MM-DD-YYYY, MM/DD/YYYY, MMDDYYYY - DD-MM-YYYY, DD/MM/YYYY, DDMMYYYY - Month name formats (DD-MMM-YYYY, MMM dd YYYY, etc.) + - Unix/Java Date.toString() format (DDD MMM DD HH:MM:SS TZ YYYY) - With or without time components - With or without timezones @@ -54,8 +55,12 @@ foam.CLASS({ // Date with month names - ALL completely unambiguous (contain letters!) // DD-MMM-YYYY, DD/MMM/YYYY, DDMMMYYYY, YYYY-DD-MMM, YYYY/DD/MMM, YYYYDDMMM // DD MMM YYYY (with spaces) + // Unix/Java Date.toString() format: DDD MMM DD HH:MM:SS TZ YYYY // NOTE: yyyyddmmmcompact must come BEFORE ddmmmyyyycompact to match correctly + // NOTE: unixdatetostring must come FIRST because it has the most specific pattern datemonthname: alt( + // Support: DDD MMM DD HH:MM:SS TZ YYYY (e.g., "Tue Apr 01 05:17:59 GMT 2025") + sym('unixdatetostring'), // Support: MMM dd yyyy (e.g., Jan 02 2025) sym('mmmddyyyyspace'), // Support: DD MMM YYYY (e.g., 15 JAN 2025) @@ -501,6 +506,47 @@ foam.CLASS({ sym('dayFlexible'), ' ', sym('month3alpha'), ' ', sym('year4') ), + // Unix/Java Date.toString() format: DDD MMM DD HH:MM:SS TZ YYYY + // e.g., "Tue Apr 01 05:17:59 GMT 2025" + // The day name (Mon, Tue, etc.) is parsed but ignored for date construction + unixdatetostring: seq( + sym('day3alpha'), ' ', // Day name (ignored) + sym('month3alpha'), ' ', // Month name + sym('dayFlexible'), ' ', // Day of month (1-31 or 01-31) + sym('hour2'), ':', sym('minute2'), ':', sym('second2'), ' ', // Time HH:MM:SS + sym('unixTimezone'), ' ', // Timezone (GMT, UTC, or +/-offset) + sym('year4') // Year + ), + + // 3-letter day name (case insensitive): Mon, Tue, Wed, Thu, Fri, Sat, Sun + day3alpha: alt( + literalIC('MON'), + literalIC('TUE'), + literalIC('WED'), + literalIC('THU'), + literalIC('FRI'), + literalIC('SAT'), + literalIC('SUN') + ), + + // Unix timezone format: GMT, UTC, or +/-HHMM + unixTimezone: alt( + literalIC('GMT'), + literalIC('UTC'), + // +HHMM or -HHMM format + seq( + chars('+-'), + repeat(range('0', '9'), null, 4, 4) + ), + // +HH:MM or -HH:MM format + seq( + chars('+-'), + repeat(range('0', '9'), null, 2, 2), + ':', + repeat(range('0', '9'), null, 2, 2) + ) + ), + // Component parsers year4: str(repeat(range('0', '9'), null, 4, 4)), // Exactly 4 digits year4_1900_2999: str(alt( diff --git a/src/foam/parse/DateParser.java b/src/foam/parse/DateParser.java new file mode 100644 index 0000000000..546e1a23ff --- /dev/null +++ b/src/foam/parse/DateParser.java @@ -0,0 +1,1422 @@ +/** + * @license + * Copyright 2025 The FOAM Authors. All Rights Reserved. + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +package foam.parse; + +import foam.lib.parse.*; +import foam.lib.parse.Optional; +import java.util.*; + +/** + * Comprehensive date and datetime parser that handles all formats from DateUtil.js. + * Uses FOAM parser framework with Grammar to support all date/datetime formats in a single parser. + * Supports both date-only and datetime formats. + * + * This Java implementation mirrors the JavaScript DateParser.js grammar structure + * to ensure identical parsing behavior across JavaScript and Java codebases. + * + * Supported formats: + * - YYYYMMDD (separated and compact with optional time) + * - MMDDYYYY (separated and compact) + * - YYMMDD (separated and compact, with 2-digit year pivot at 50) + * - DDMMYYYY (via opt_name only, separated and compact) + * - YYYYDDMM (via opt_name only, separated and compact) + * - DDMMMYYYY, YYYYDDMMM (month names: JAN, FEB, etc.) + * - Timezone support: Z, +HH:MM, +HHMM, +HH + * + * Usage: + * DateParser parser = new DateParser(); + * Date date = parser.parseString("2025-01-15"); + * Date datetime = parser.parseString("2025-01-15T14:30:45"); + */ +public class DateParser { + + public enum DateParseMode { DATE, STRING, DATETIME, DATETIME_UTC } + + private Grammar grammar_; + + /** + * If true, throws errors for invalid dates. If false, logs warnings and returns MAX_DATE. + * Static because DateParser is used as a singleton pattern - each call creates a new instance + * but the strictValidation setting should be shared across all instances (like JavaScript Singleton). + */ + private static boolean strictValidation_ = false; + + public boolean getStrictValidation() { return strictValidation_; } + public void setStrictValidation(boolean v) { strictValidation_ = v; } + + /** + * Maximum date value for invalid dates + */ + public static final Date MAX_DATE = new Date(Long.MAX_VALUE); + + /** + * Invalid date marker + */ + public static final Date INVALID_DATE = new Date(Long.MIN_VALUE); + + /** + * Constructor - initializes the grammar + */ + public DateParser() { + grammar_ = getGrammar(); + } + + /** + * Convenience method: parse with grammar and return String value + */ + private Object parseStringWithGrammar(String str, String opt_name) { + if ( str == null || str.trim().isEmpty() ) { + return null; + } + + try { + ParserContext x = new ParserContextImpl(); + StringPStream ps = new StringPStream(str); + + Parser startSymbol = grammar_.sym(opt_name != null && !opt_name.isEmpty() ? opt_name : "START"); + PStream parseResult = ps.apply(startSymbol, x); + + if ( parseResult == null ) { + return null; + } + + return parseResult.value(); + } catch (Exception e) { + return null; + } + } + + /** + * Parse a date/datetime string and return a Date object. + * Auto-detects format and handles time if present. + * Throws RuntimeException for invalid formats if strictValidation is true, + * otherwise returns MAX_DATE with warning. + * + * @param str The date string to parse + * @param opt_name Optional grammar symbol name to use (e.g., "ddmmyyyy", "yyyyddmm") + * @return Parsed Date object + * @throws RuntimeException if format is unsupported and strictValidation is true + */ + public Date parseString(String str, String opt_name) { + if ( str == null || str.trim().isEmpty() ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported Date format: empty or null string"); + } + System.err.println("Warning: Invalid date: empty or null string; assuming MAX_DATE."); + return MAX_DATE; + } + + str = str.trim(); + StringPStream sps = new StringPStream(str); + ParserContext x = new ParserContextImpl(); + x.set("dateParseMode", DateParseMode.STRING); // STRING mode: date-only → noon GMT, with time → local time + + PStream parseResult = grammar_.parse(sps, x, opt_name); + if ( parseResult == null || parseResult.value() == null ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported Date format: " + str); + } + System.err.println("Warning: Invalid date: \"" + str + "\"; assuming MAX_DATE."); + return MAX_DATE; + } + + return (Date) parseResult.value(); + } + + /** + * Convenience method: parseString with default grammar START symbol + */ + public Date parseString(String str) { + return parseString(str, null); + } + + /** + * Parse a date string - ignores any time component and returns date at noon GMT. + * Throws RuntimeException for invalid formats if strictValidation is true, + * otherwise returns MAX_DATE with warning. + * + * @param str The date string to parse + * @param opt_name Optional grammar symbol name + * @return Parsed Date object at noon GMT, or MAX_DATE if invalid and strictValidation is false + */ + public Date parseDateString(String str, String opt_name) { + if ( str == null || str.trim().isEmpty() ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported Date format: empty or null string"); + } + System.err.println("Warning: Invalid date: empty or null string; assuming MAX_DATE."); + return MAX_DATE; + } + + str = str.trim(); + StringPStream sps = new StringPStream(str); + ParserContext x = new ParserContextImpl(); + x.set("dateParseMode", DateParseMode.DATE); + + PStream parseResult = grammar_.parse(sps, x, opt_name); + if ( parseResult == null || parseResult.value() == null ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported Date format: " + str); + } + System.err.println("Warning: Invalid date: \"" + str + "\"; assuming MAX_DATE."); + return MAX_DATE; + } + + return (Date) parseResult.value(); + } + + /** + * Convenience method: parseDateString with default grammar + */ + public Date parseDateString(String str) { + return parseDateString(str, null); + } + + /** + * Parse a datetime string using local time. + * Uses time if present, otherwise sets to noon. + * If timezone is present, converts to UTC. + * Throws RuntimeException for invalid formats if strictValidation is true, + * otherwise returns MAX_DATE with warning. + * + * @param str The datetime string to parse + * @param opt_name Optional grammar symbol name + * @return Parsed Date object in local time, or MAX_DATE if invalid and strictValidation is false + */ + public Date parseDateTime(String str, String opt_name) { + if ( str == null || str.trim().isEmpty() ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported DateTime format: empty or null string"); + } + System.err.println("Warning: Invalid datetime: empty or null string; assuming MAX_DATE."); + return MAX_DATE; + } + + str = str.trim(); + StringPStream sps = new StringPStream(str); + ParserContext x = new ParserContextImpl(); + x.set("dateParseMode", DateParseMode.DATETIME); + + PStream parseResult = grammar_.parse(sps, x, opt_name); + if ( parseResult == null || parseResult.value() == null ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported DateTime format: " + str); + } + System.err.println("Warning: Invalid datetime: \"" + str + "\"; assuming MAX_DATE."); + return MAX_DATE; + } + + return (Date) parseResult.value(); + } + + /** + * Convenience method: parseDateTime with default grammar + */ + public Date parseDateTime(String str) { + return parseDateTime(str, null); + } + + /** + * Parse a datetime string using UTC time. + * Uses time if present, otherwise sets to midnight. + * If timezone is present, converts to UTC. + * Throws RuntimeException for invalid formats if strictValidation is true, + * otherwise returns MAX_DATE with warning. + * + * @param str The datetime string to parse + * @param opt_name Optional grammar symbol name + * @return Parsed Date object in UTC, or MAX_DATE if invalid and strictValidation is false + */ + public Date parseDateTimeUTC(String str, String opt_name) { + if ( str == null || str.trim().isEmpty() ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported DateTime format: empty or null string"); + } + System.err.println("Warning: Invalid datetime: empty or null string; assuming MAX_DATE."); + return MAX_DATE; + } + + str = str.trim(); + StringPStream sps = new StringPStream(str); + ParserContext x = new ParserContextImpl(); + x.set("dateParseMode", DateParseMode.DATETIME_UTC); + + PStream parseResult = grammar_.parse(sps, x, opt_name); + if ( parseResult == null || parseResult.value() == null ) { + if ( strictValidation_ ) { + throw new RuntimeException("Unsupported DateTime format: " + str); + } + System.err.println("Warning: Invalid datetime: \"" + str + "\"; assuming MAX_DATE."); + return MAX_DATE; + } + + return (Date) parseResult.value(); + } + + /** + * Convenience method: parseDateTimeUTC with default grammar + */ + public Date parseDateTimeUTC(String str) { + return parseDateTimeUTC(str, null); + } + + // ========== Helper Methods ========== + + /** + * Flatten timezone array from parser into a string + */ + private String flattenTimezone(Object tzArray) { + if ( tzArray == null ) return null; + if ( tzArray instanceof String ) return (String) tzArray; + + // Handle array structure from parser + if ( tzArray instanceof Object[] ) { + StringBuilder result = new StringBuilder(); + Object[] arr = (Object[]) tzArray; + for ( Object item : arr ) { + if ( item instanceof Object[] ) { + for ( Object subItem : (Object[]) item ) { + result.append(subItem); + } + } else if ( item != null ) { + result.append(item); + } + } + return result.toString(); + } + + return String.valueOf(tzArray); + } + + /** + * Normalize Unix timezone format to standard format. + * GMT/UTC -> "Z", numeric offsets are flattened to string. + */ + private String normalizeUnixTimezone(Object tz) { + if ( tz == null ) return null; + + // Handle string values (GMT, UTC) + if ( tz instanceof String ) { + String tzUpper = ((String) tz).toUpperCase(); + if ( tzUpper.equals("GMT") || tzUpper.equals("UTC") ) { + return "Z"; + } + return (String) tz; + } + + // Handle array values from Seq parser - offset format like ['+', ['0', '5', '3', '0']] + if ( tz instanceof Object[] ) { + return flattenTimezone(tz); + } + + return null; + } + + /** + * Parse timezone string and return offset in minutes. + * Z means UTC (0). +05:30 means +330 minutes. + */ + private int parseTimezone(String tz) { + if ( tz == null || tz.equals("Z") ) return 0; + + int sign = tz.charAt(0) == '+' ? 1 : -1; + String nums = tz.substring(1).replace(":", ""); + + int hours, minutes; + if ( nums.length() >= 4 ) { + // HHMM format + hours = Integer.parseInt(nums.substring(0, 2)); + minutes = Integer.parseInt(nums.substring(2, 4)); + } else if ( nums.length() == 2 ) { + // HH format (no minutes) + hours = Integer.parseInt(nums); + minutes = 0; + } else { + return 0; + } + + return sign * (hours * 60 + minutes); + } + + /** + * Converts 2-digit year using fixed pivot at 50: + * - Years 00-49 map to 2000-2049 + * - Years 50-99 map to 1950-1999 + */ + private int convertTwoDigitYear(int twoDigitYear) { + // Fixed pivot at 50 + if ( twoDigitYear < 50 ) { + return 2000 + twoDigitYear; + } + return 1900 + twoDigitYear; + } + + /** + * Converts 3-letter month abbreviation to 0-based month index (JAN→0, FEB→1, etc.) + */ + private int parseMonthName(String monthName) { + String month = monthName.toUpperCase(); + switch (month) { + case "JAN": return 0; + case "FEB": return 1; + case "MAR": return 2; + case "APR": return 3; + case "MAY": return 4; + case "JUN": return 5; + case "JUL": return 6; + case "AUG": return 7; + case "SEP": return 8; + case "OCT": return 9; + case "NOV": return 10; + case "DEC": return 11; + default: + if ( strictValidation_ ) { + throw new RuntimeException("Invalid month name: \"" + monthName + "\""); + } + System.err.println("Warning: Invalid month name: \"" + monthName + "\"; assuming January."); + return 0; + } + } + + /** + * Build a Date object from parsed components based on mode + */ + private Date buildDate(DateParseMode mode, int year, int month, int day, + int hour, int minute, int second, int ms, String tz) { + Calendar cal; + switch (mode) { + case DATE: + // Always noon GMT - for parseDateString + cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal.clear(); + cal.set(year, month, day, 12, 0, 0); + return cal.getTime(); + case STRING: + // For parseString: date-only → noon GMT, with time → local time + if ( hour < 0 && minute < 0 && second < 0 ) { + // No time components - return noon GMT + cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal.clear(); + cal.set(year, month, day, 12, 0, 0); + return cal.getTime(); + } + // Has time - return local time + if ( tz != null ) { + int offset = parseTimezone(tz); + cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal.clear(); + cal.set(year, month, day, hour >= 0 ? hour : 0, + minute >= 0 ? minute : 0, second >= 0 ? second : 0); + if ( ms >= 0 ) cal.set(Calendar.MILLISECOND, ms); + cal.add(Calendar.MINUTE, -offset); + return cal.getTime(); + } + cal = Calendar.getInstance(); + cal.clear(); + cal.set(year, month, day, hour >= 0 ? hour : 0, + minute >= 0 ? minute : 0, second >= 0 ? second : 0); + if ( ms >= 0 ) cal.set(Calendar.MILLISECOND, ms); + return cal.getTime(); + case DATETIME: + // Always local time with default hour 12 - for parseDateTime + if ( tz != null ) { + int offset = parseTimezone(tz); + cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal.clear(); + cal.set(year, month, day, hour >= 0 ? hour : 12, + minute >= 0 ? minute : 0, second >= 0 ? second : 0); + if ( ms >= 0 ) cal.set(Calendar.MILLISECOND, ms); + cal.add(Calendar.MINUTE, -offset); + return cal.getTime(); + } + cal = Calendar.getInstance(); + cal.clear(); + cal.set(year, month, day, hour >= 0 ? hour : 12, + minute >= 0 ? minute : 0, second >= 0 ? second : 0); + if ( ms >= 0 ) cal.set(Calendar.MILLISECOND, ms); + return cal.getTime(); + case DATETIME_UTC: + // Always UTC - for parseDateTimeUTC + cal = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal.clear(); + cal.set(year, month, day, hour >= 0 ? hour : 0, + minute >= 0 ? minute : 0, second >= 0 ? second : 0); + if ( ms >= 0 ) cal.set(Calendar.MILLISECOND, ms); + if ( tz != null ) cal.add(Calendar.MINUTE, -parseTimezone(tz)); + return cal.getTime(); + default: + return null; + } + } + + /** + * Parse integer from array or return default value + */ + private int parseIntOrDefault(Object[] v, int idx, int defaultVal) { + if ( v.length <= idx || v[idx] == null ) return defaultVal; + return Integer.parseInt((String) v[idx]); + } + + /** + * Parse fractional seconds string (1-6 digits) and convert to milliseconds (0-999). + * Pads short strings with trailing zeros (e.g., "1" -> 100, "12" -> 120). + * Truncates long strings to 3 digits (e.g., "123456" -> 123). + */ + private int parseFractionalSeconds(String fracStr) { + if ( fracStr == null || fracStr.isEmpty() ) return -1; + + // Pad to 3 digits if shorter + if ( fracStr.length() < 3 ) { + while ( fracStr.length() < 3 ) { + fracStr = fracStr + "0"; + } + } + + // Truncate to 3 digits if longer + if ( fracStr.length() > 3 ) { + fracStr = fracStr.substring(0, 3); + } + + return Integer.parseInt(fracStr); + } + + /** + * Extract fractional seconds from parsed value array if present + */ + private int extractFractionalSeconds(Object[] v, int fracIdx) { + if ( v.length <= fracIdx || v[fracIdx] == null ) return -1; + return parseFractionalSeconds((String) v[fracIdx]); + } + + /** + * Extract timezone from parsed value array + */ + private String extractTimezone(Object[] v) { + if ( v.length == 0 ) return null; + Object last = v[v.length - 1]; + if ( last == null ) return null; + if ( "Z".equals(last) ) return "Z"; + if ( !(last instanceof String) ) return flattenTimezone(last); + return null; + } + + + // ========== Grammar Definition ========== + + /** + * Creates and returns the complete grammar for date parsing. + * This mirrors the JavaScript DateParser.js grammar structure exactly. + */ + private Grammar getGrammar() { + Grammar grammar = new Grammar(); + + // START symbol - entry point + grammar.addSymbol("START", grammar.sym("dateOrDatetime")); + + // Main entry point - tries all formats including month names + // NOTE: Month name formats go FIRST because they contain letters (unambiguous!) + // Timestamps (10-13 digits) go LAST to avoid matching date formats like YYYYMMDDHH + // NOTE: YYMMDD is NOT in the main entry point because it's ambiguous with MMDDYY + // (e.g., "25/01/15" could be YY-MM-DD or MM-DD-YY). Use opt_name='yymmdd' to parse explicitly. + grammar.addSymbol("dateOrDatetime", new Alt( + grammar.sym("date-monthname"), // Month name formats first (unambiguous) + grammar.sym("yyyymmdd"), + grammar.sym("mmddyyyy"), + grammar.sym("mmddyy"), // 2-digit year US format (MM-DD-YY) + grammar.sym("timestamp") // Unix/JS timestamps (10-13 digits, must not match date formats) + )); + + // Unix timestamp (10 digits, seconds) or JavaScript timestamp (13 digits, milliseconds) + grammar.addSymbol("timestamp", new Alt( + grammar.sym("timestamp13"), // 13-digit JavaScript millisecond timestamp (always safe) + grammar.sym("timestamp10") // 10-digit Unix second timestamp (checked after date formats fail) + )); + grammar.addSymbol("timestamp13", new Join(new Repeat(Range.create('0', '9'), null, 13, 13))); + grammar.addSymbol("timestamp10", new Join(new Repeat(Range.create('0', '9'), null, 10, 10))); + + // Date with month names - ALL completely unambiguous (contain letters!) + grammar.addSymbol("date-monthname", new Alt( + grammar.sym("unix-date-tostring"), // Unix/Java Date.toString(): "Tue Apr 01 05:17:59 GMT 2025" + grammar.sym("mmmddyyyy-space"), // MMM dd yyyy (e.g., Jan 02 2025) + grammar.sym("ddmmmyyyy-space"), // DD MMM YYYY (e.g., 15 JAN 2025) + grammar.sym("ddmmmyyyy-sep"), + grammar.sym("yyyyddmmm-sep"), + grammar.sym("yyyyddmmm-compact"), + grammar.sym("ddmmmyyyy-compact") + )); + + // ========== Component Parsers ========== + + // year4: Exactly 4 digits + grammar.addSymbol("year4", new Join(new Repeat(Range.create('0', '9'), null, 4, 4))); + + // year4_1900_2999: Years 1900-2999 only + grammar.addSymbol("year4_1900_2999", new Join(new Alt( + new Seq(Literal.create("1"), Literal.create("9"), Range.create('0', '9'), Range.create('0', '9')), + new Seq(Literal.create("2"), Range.create('0', '9'), Range.create('0', '9'), Range.create('0', '9')) + ))); + + // year2: 2 digits + grammar.addSymbol("year2", new Join(new Seq(Range.create('0', '9'), Range.create('0', '9')))); + + // month2: 01-12 (strict 2 digits for compact formats) + grammar.addSymbol("month2", new Join(new Alt( + new Seq(Literal.create("0"), Range.create('1', '9')), // 01-09 + new Seq(Literal.create("1"), Range.create('0', '2')) // 10-12 + ))); + + // monthFlexible: 1 or 2 digits (for formats with separators) + // Allows slightly out-of-range values for JavaScript Date normalization + grammar.addSymbol("monthFlexible", new Alt( + new Join(new Seq(Literal.create("1"), Range.create('0', '9'))), // 10-19 + new Join(new Seq(Literal.create("0"), Range.create('0', '9'))), // 00-09 + new Join(Range.create('0', '9')) // 0-9 (single digit) + )); + + // day2: 01-31 (strict 2 digits for compact formats) + grammar.addSymbol("day2", new Join(new Alt( + new Seq(Literal.create("0"), Range.create('1', '9')), // 01-09 + new Seq(Range.create('1', '2'), Range.create('0', '9')), // 10-29 + new Seq(Literal.create("3"), Range.create('0', '1')) // 30-31 + ))); + + // dayFlexible: 1 or 2 digits (for formats with separators) + // Day: 0-39 range to allow normalization (e.g., Feb 30 → Mar 2) + grammar.addSymbol("dayFlexible", new Alt( + new Join(new Seq(Literal.create("3"), Range.create('0', '9'))), // 30-39 + new Join(new Seq(Range.create('0', '2'), Range.create('0', '9'))), // 00-29 + new Join(Range.create('0', '9')) // 0-9 (single digit) + )); + + // hour2: 00-23 (accept any 2 digits, validation in action) + grammar.addSymbol("hour2", new Join(new Seq(Range.create('0', '2'), Range.create('0', '9')))); + + // minute2: 00-59 + grammar.addSymbol("minute2", new Join(new Seq(Range.create('0', '5'), Range.create('0', '9')))); + + // second2: 00-59 + grammar.addSymbol("second2", new Join(new Seq(Range.create('0', '5'), Range.create('0', '9')))); + + // fractionalSeconds: 1-6 digits for milliseconds or microseconds + grammar.addSymbol("fractionalSeconds", new Join(new Repeat(Range.create('0', '9'), null, 1, 6))); + + // millisecond3: 3 digits (kept for backward compatibility) + grammar.addSymbol("millisecond3", new Join(new Repeat(Range.create('0', '9'), null, 3, 3))); + + // month3alpha: JAN, FEB, MAR, etc. (case insensitive) + grammar.addSymbol("month3alpha", new Alt( + new LiteralIC("JAN"), new LiteralIC("FEB"), new LiteralIC("MAR"), + new LiteralIC("APR"), new LiteralIC("MAY"), new LiteralIC("JUN"), + new LiteralIC("JUL"), new LiteralIC("AUG"), new LiteralIC("SEP"), + new LiteralIC("OCT"), new LiteralIC("NOV"), new LiteralIC("DEC") + )); + + // day3alpha: Mon, Tue, Wed, Thu, Fri, Sat, Sun (case insensitive) + grammar.addSymbol("day3alpha", new Alt( + new LiteralIC("MON"), new LiteralIC("TUE"), new LiteralIC("WED"), + new LiteralIC("THU"), new LiteralIC("FRI"), new LiteralIC("SAT"), new LiteralIC("SUN") + )); + + // timezoneAlpha: GMT, UTC (case insensitive) + grammar.addSymbol("timezoneAlpha", new Alt( + new LiteralIC("GMT"), new LiteralIC("UTC") + )); + + // unixTimezone: GMT, UTC, or +/-HHMM or +/-HH:MM (for Unix Date.toString() format) + grammar.addSymbol("unixTimezone", new Alt( + new LiteralIC("GMT"), + new LiteralIC("UTC"), + // +HHMM or -HHMM format + new Seq( + new Chars("+-"), + new Repeat(Range.create('0', '9'), null, 4, 4) + ), + // +HH:MM or -HH:MM format + new Seq( + new Chars("+-"), + new Repeat(Range.create('0', '9'), null, 2, 2), + Literal.create(":"), + new Repeat(Range.create('0', '9'), null, 2, 2) + ) + )); + + // datetimesep: T or space (datetime separator) + grammar.addSymbol("datetimesep", new Chars("T ")); + + // timezone: Z or +/-HH:MM or +/-HHMM or +/-HH + grammar.addSymbol("timezone", new Alt( + Literal.create("Z"), + // +HH:MM format + new Seq( + new Chars("+-"), + new Repeat(Range.create('0', '9'), null, 2, 2), + Literal.create(":"), + new Repeat(Range.create('0', '9'), null, 2, 2) + ), + // +HHMM format (no colon) + new Seq( + new Chars("+-"), + new Repeat(Range.create('0', '9'), null, 4, 4) + ), + // +HH format (hours only) + new Seq( + new Chars("+-"), + new Repeat(Range.create('0', '9'), null, 2, 2) + ) + )); + + // ========== YYYYMMDD Formats ========== + + grammar.addSymbol("yyyymmdd", new Alt( + grammar.sym("yyyymmddhhmmss-compact"), + grammar.sym("yyyymmdd-compact"), + grammar.sym("yyyymmdd-sep") + )); + + // YYYYMMDD with separators and optional time + // Supports single-digit months and days (e.g., 2025-1-5) + grammar.addSymbol("yyyymmdd-sep", new Alt( + // With fractional seconds (milliseconds/microseconds) and timezone + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible") + ) + )); + + // YYYYMMDD compact: 8 digits + grammar.addSymbol("yyyymmdd-compact", new Join( + new Seq(grammar.sym("year4_1900_2999"), grammar.sym("month2"), grammar.sym("day2")) + )); + + // YYYYMMDDHHMMSS compact: 14 digits + grammar.addSymbol("yyyymmddhhmmss-compact", new Seq( + grammar.sym("year4_1900_2999"), grammar.sym("month2"), grammar.sym("day2"), + grammar.sym("hour2"), grammar.sym("minute2"), grammar.sym("second2") + )); + + // ========== MMDDYYYY Formats ========== + + grammar.addSymbol("mmddyyyy", new Alt( + grammar.sym("mmddyyyy-compact"), + grammar.sym("mmddyyyy-sep") + )); + + // MMDDYYYY with separators - supports single-digit month/day (e.g., 7/2/2025) + grammar.addSymbol("mmddyyyy-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year4"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year4"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year4"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year4") + ) + )); + + // MMDDYYYY compact: 8 digits with validated month (01-12), day (01-31), year + grammar.addSymbol("mmddyyyy-compact", new Alt( + // With space and compact time (HHMMSS - no colons) + new Seq( + new Join(new Seq(grammar.sym("month2"), grammar.sym("day2"), grammar.sym("year4"))), + grammar.sym("datetimesep"), + grammar.sym("hour2"), grammar.sym("minute2"), grammar.sym("second2") + ), + // With space and time with colons + new Seq( + new Join(new Seq(grammar.sym("month2"), grammar.sym("day2"), grammar.sym("year4"))), + grammar.sym("datetimesep"), + grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(new Seq(Literal.create(":"), grammar.sym("second2"))), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Join(new Seq(grammar.sym("month2"), grammar.sym("day2"), grammar.sym("year4"))) + )); + + // ========== MMDDYY Formats (2-digit year) ========== + + grammar.addSymbol("mmddyy", new Alt( + grammar.sym("mmddyy-compact"), + grammar.sym("mmddyy-sep") + )); + + // MMDDYY with separators - supports single-digit month/day (e.g., 7/2/25) + grammar.addSymbol("mmddyy-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year2"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year2"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year2"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("year2") + ) + )); + + // MMDDYY compact: 6 digits with validated month (01-12), day (01-31), year + grammar.addSymbol("mmddyy-compact", new Join(new Seq(grammar.sym("month2"), grammar.sym("day2"), grammar.sym("year2")))); + + // ========== YYMMDD Formats ========== + + grammar.addSymbol("yymmdd", new Alt( + grammar.sym("yymmdd-compact"), + grammar.sym("yymmdd-sep") + )); + + // YYMMDD with separators - supports single-digit months/days (e.g., 25-1-5) + grammar.addSymbol("yymmdd-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("dayFlexible") + ) + )); + + // YYMMDD compact: 6 digits with validated year, month (01-12), day (01-31) + grammar.addSymbol("yymmdd-compact", new Join(new Seq(grammar.sym("year2"), grammar.sym("month2"), grammar.sym("day2")))); + + // ========== DDMMYYYY Formats (via opt_name only) ========== + + grammar.addSymbol("ddmmyyyy", new Alt( + grammar.sym("ddmmyyyy-sep"), + grammar.sym("ddmmyy-sep"), + grammar.sym("ddmmyyyy-compact"), + grammar.sym("ddmmyy-compact") + )); + + // DDMMYYYY with separators - supports single-digit days and months (e.g., 5-1-2025) + grammar.addSymbol("ddmmyyyy-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year4"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year4"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year4"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year4") + ) + )); + + // DDMMYYYY compact: 8 digits with validated day (01-31), month (01-12), year + grammar.addSymbol("ddmmyyyy-compact", new Alt( + // With space and compact time (HHMMSS - no colons) + new Seq( + new Join(new Seq(grammar.sym("day2"), grammar.sym("month2"), grammar.sym("year4"))), + grammar.sym("datetimesep"), + grammar.sym("hour2"), grammar.sym("minute2"), grammar.sym("second2") + ), + // With space and time with colons + new Seq( + new Join(new Seq(grammar.sym("day2"), grammar.sym("month2"), grammar.sym("year4"))), + grammar.sym("datetimesep"), + grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(new Seq(Literal.create(":"), grammar.sym("second2"))), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Join(new Seq(grammar.sym("day2"), grammar.sym("month2"), grammar.sym("year4"))) + )); + + // DDMMYY with separators - supports single-digit days and months (e.g., 5-1-25) + grammar.addSymbol("ddmmyy-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year2"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year2"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year2"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), new Chars("-/"), grammar.sym("year2") + ) + )); + + // DDMMYY compact: 6 digits with validated day (01-31), month (01-12), year + grammar.addSymbol("ddmmyy-compact", new Join(new Seq(grammar.sym("day2"), grammar.sym("month2"), grammar.sym("year2")))); + + // ========== YYYYDDMM Formats (via opt_name only) ========== + + grammar.addSymbol("yyyyddmm", new Alt( + grammar.sym("yyyyddmm-compact"), + grammar.sym("yyyyddmm-sep"), + grammar.sym("yyddmm") + )); + + // YYYYDDMM with separators - supports single-digit days and months (e.g., 2025-5-1) + grammar.addSymbol("yyyyddmm-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible") + ) + )); + + // YYYYDDMM compact: 8 digits with validated year, day (01-31), month (01-12) + grammar.addSymbol("yyyyddmm-compact", new Alt( + // With space and compact time (HHMMSS - no colons) + new Seq( + new Join(new Seq(grammar.sym("year4"), grammar.sym("day2"), grammar.sym("month2"))), + grammar.sym("datetimesep"), + grammar.sym("hour2"), grammar.sym("minute2"), grammar.sym("second2") + ), + // With space and time with colons + new Seq( + new Join(new Seq(grammar.sym("year4"), grammar.sym("day2"), grammar.sym("month2"))), + grammar.sym("datetimesep"), + grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(new Seq(Literal.create(":"), grammar.sym("second2"))), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Join(new Seq(grammar.sym("year4"), grammar.sym("day2"), grammar.sym("month2"))) + )); + + grammar.addSymbol("yyddmm", new Alt( + grammar.sym("yyddmm-compact"), + grammar.sym("yyddmm-sep") + )); + + // YYDDMM with separators - supports single-digit days and months (e.g., 25-5-1) + grammar.addSymbol("yyddmm-sep", new Alt( + // With fractional seconds and timezone + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), Literal.create("."), grammar.sym("fractionalSeconds"), + new Optional(grammar.sym("timezone")) + ), + // With seconds and timezone + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + Literal.create(":"), grammar.sym("second2"), + new Optional(grammar.sym("timezone")) + ), + // With minutes and timezone + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible"), + grammar.sym("datetimesep"), grammar.sym("hour2"), Literal.create(":"), grammar.sym("minute2"), + new Optional(grammar.sym("timezone")) + ), + // Date only + new Seq( + grammar.sym("year2"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("monthFlexible") + ) + )); + + // YYDDMM compact: 6 digits with validated year, day (01-31), month (01-12) + grammar.addSymbol("yyddmm-compact", new Join(new Seq(grammar.sym("year2"), grammar.sym("day2"), grammar.sym("month2")))); + + // ========== Month Name Formats ========== + + // YYYYDDMMM with separators - supports single-digit days (e.g., 2025-5-JAN) + grammar.addSymbol("yyyyddmmm-sep", new Seq( + grammar.sym("year4"), new Chars("-/"), grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("month3alpha") + )); + + grammar.addSymbol("yyyyddmmm-compact", new Seq( + grammar.sym("year4"), grammar.sym("day2"), grammar.sym("month3alpha") + )); + + // DDMMMYYYY with separators - supports single-digit days (e.g., 5-JAN-2025) + grammar.addSymbol("ddmmmyyyy-sep", new Seq( + grammar.sym("dayFlexible"), new Chars("-/"), grammar.sym("month3alpha"), new Chars("-/"), grammar.sym("year4") + )); + + grammar.addSymbol("ddmmmyyyy-compact", new Seq( + grammar.sym("day2"), grammar.sym("month3alpha"), grammar.sym("year4") + )); + + // MMM dd yyyy with spaces: "Jan 02 2025" or "Jan 2 2025" - supports single-digit days + grammar.addSymbol("mmmddyyyy-space", new Seq( + grammar.sym("month3alpha"), Literal.create(" "), grammar.sym("dayFlexible"), Literal.create(" "), grammar.sym("year4") + )); + + // DD MMM YYYY with spaces: "15 JAN 2025" or "5 JAN 2025" - supports single-digit days + grammar.addSymbol("ddmmmyyyy-space", new Seq( + grammar.sym("dayFlexible"), Literal.create(" "), grammar.sym("month3alpha"), Literal.create(" "), grammar.sym("year4") + )); + + // Unix/Java Date.toString() format: "Tue Apr 01 05:17:59 GMT 2025" + // Format: DDD MMM DD HH:MM:SS TZ YYYY + grammar.addSymbol("unix-date-tostring", new Seq( + grammar.sym("day3alpha"), // Day name (Tue, Wed, etc.) - ignored for date construction + Literal.create(" "), + grammar.sym("month3alpha"), // Month name (Jan, Feb, etc.) + Literal.create(" "), + grammar.sym("dayFlexible"), // Day of month (01-31, can be single digit) + Literal.create(" "), + grammar.sym("hour2"), // Hour (00-23) + Literal.create(":"), + grammar.sym("minute2"), // Minute (00-59) + Literal.create(":"), + grammar.sym("second2"), // Second (00-59) + Literal.create(" "), + grammar.sym("unixTimezone"), // Timezone (GMT, UTC, or +/-offset) + Literal.create(" "), + grammar.sym("year4") // Year (4 digits) + )); + + // ========== Add Actions ========== + addActions(grammar); + + return grammar; + } + + /** + * Add action handlers to convert parsed arrays to Date objects + */ + private void addActions(Grammar grammar) { + final DateParser self = this; + + // YYYYMMDD-Sep action: [YYYY, sep, MM, sep, DD] or with time + // With fractional seconds: [YYYY, sep, MM, sep, DD, T, HH, :, MM, :, SS, ., fracSec, tz] + grammar.addAction("yyyymmdd-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[0]), + Integer.parseInt((String) v[2]) - 1, + Integer.parseInt((String) v[4]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + self.extractFractionalSeconds(v, 12), + self.extractTimezone(v)); + }); + + // YYYYMMDD-Compact action: "20250115" + grammar.addAction("yyyymmdd-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt(v.substring(0, 4)), + Integer.parseInt(v.substring(4, 6)) - 1, + Integer.parseInt(v.substring(6, 8)), + -1, -1, -1, -1, null); + }); + + // YYYYMMDDHHMMSS-Compact action: [year, month, day, hour, minute, second] + grammar.addAction("yyyymmddhhmmss-compact", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[0]), + Integer.parseInt((String) v[1]) - 1, + Integer.parseInt((String) v[2]), + Integer.parseInt((String) v[3]), + Integer.parseInt((String) v[4]), + Integer.parseInt((String) v[5]), + -1, null); + }); + + // MMDDYYYY-Sep action + grammar.addAction("mmddyyyy-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[4]), + Integer.parseInt((String) v[0]) - 1, + Integer.parseInt((String) v[2]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // MMDDYYYY-Compact action: "01152025" + grammar.addAction("mmddyyyy-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt(v.substring(4, 8)), + Integer.parseInt(v.substring(0, 2)) - 1, + Integer.parseInt(v.substring(2, 4)), + -1, -1, -1, -1, null); + }); + + // YYMMDD-Sep action + grammar.addAction("yymmdd-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt((String) v[0]); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt((String) v[2]) - 1, + Integer.parseInt((String) v[4]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // YYMMDD-Compact action: "250115" + grammar.addAction("yymmdd-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt(v.substring(0, 2)); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt(v.substring(2, 4)) - 1, + Integer.parseInt(v.substring(4, 6)), + -1, -1, -1, -1, null); + }); + + // DDMMYYYY-Sep action + grammar.addAction("ddmmyyyy-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[4]), + Integer.parseInt((String) v[2]) - 1, + Integer.parseInt((String) v[0]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // DDMMYYYY-Compact action: "15012025" + grammar.addAction("ddmmyyyy-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt(v.substring(4, 8)), + Integer.parseInt(v.substring(2, 4)) - 1, + Integer.parseInt(v.substring(0, 2)), + -1, -1, -1, -1, null); + }); + + // DDMMYY-Sep action + grammar.addAction("ddmmyy-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt((String) v[4]); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt((String) v[2]) - 1, + Integer.parseInt((String) v[0]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // DDMMYY-Compact action: "150125" + grammar.addAction("ddmmyy-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt(v.substring(4, 6)); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt(v.substring(2, 4)) - 1, + Integer.parseInt(v.substring(0, 2)), + -1, -1, -1, -1, null); + }); + + // YYYYDDMM-Sep action + grammar.addAction("yyyyddmm-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[0]), + Integer.parseInt((String) v[4]) - 1, + Integer.parseInt((String) v[2]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // YYYYDDMM-Compact action: "20251501" + grammar.addAction("yyyyddmm-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt(v.substring(0, 4)), + Integer.parseInt(v.substring(6, 8)) - 1, + Integer.parseInt(v.substring(4, 6)), + -1, -1, -1, -1, null); + }); + + // YYDDMM-Sep action + grammar.addAction("yyddmm-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt((String) v[0]); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt((String) v[4]) - 1, + Integer.parseInt((String) v[2]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // YYDDMM-Compact action: "251501" + grammar.addAction("yyddmm-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt(v.substring(0, 2)); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt(v.substring(4, 6)) - 1, + Integer.parseInt(v.substring(2, 4)), + -1, -1, -1, -1, null); + }); + + // DDMMMYYYY-Sep action: [DD, sep, MMM, sep, YYYY] + grammar.addAction("ddmmmyyyy-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[4]), + self.parseMonthName((String) v[2]), + Integer.parseInt((String) v[0]), + -1, -1, -1, -1, null); + }); + + // DDMMMYYYY-Compact action: [DD, MMM, YYYY] + grammar.addAction("ddmmmyyyy-compact", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[2]), + self.parseMonthName((String) v[1]), + Integer.parseInt((String) v[0]), + -1, -1, -1, -1, null); + }); + + // YYYYDDMMM-Sep action: [YYYY, sep, DD, sep, MMM] + grammar.addAction("yyyyddmmm-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[0]), + self.parseMonthName((String) v[4]), + Integer.parseInt((String) v[2]), + -1, -1, -1, -1, null); + }); + + // YYYYDDMMM-Compact action: [YYYY, DD, MMM] + grammar.addAction("yyyyddmmm-compact", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[0]), + self.parseMonthName((String) v[2]), + Integer.parseInt((String) v[1]), + -1, -1, -1, -1, null); + }); + + // Timestamp actions - convert timestamp strings directly to Date objects + + // 13-digit JavaScript timestamp (milliseconds since epoch) + grammar.addAction("timestamp13", (val, x) -> { + String v = (String) val; + return new Date(Long.parseLong(v)); + }); + + // 10-digit Unix timestamp (seconds since epoch) + grammar.addAction("timestamp10", (val, x) -> { + String v = (String) val; + return new Date(Long.parseLong(v) * 1000); + }); + + // MMM dd yyyy space action: [MMM, ' ', DD, ' ', YYYY] + grammar.addAction("mmmddyyyy-space", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[4]), + self.parseMonthName((String) v[0]), + Integer.parseInt((String) v[2]), + -1, -1, -1, -1, null); + }); + + // DD MMM YYYY space action: [DD, ' ', MMM, ' ', YYYY] + grammar.addAction("ddmmmyyyy-space", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + return self.buildDate(mode, + Integer.parseInt((String) v[4]), + self.parseMonthName((String) v[2]), + Integer.parseInt((String) v[0]), + -1, -1, -1, -1, null); + }); + + // Unix/Java Date.toString() action: [DDD, ' ', MMM, ' ', DD, ' ', HH, ':', MM, ':', SS, ' ', TZ, ' ', YYYY] + // Index mapping: 0=day_name, 1=' ', 2=month, 3=' ', 4=day, 5=' ', 6=hour, 7=':', 8=min, 9=':', 10=sec, 11=' ', 12=tz, 13=' ', 14=year + grammar.addAction("unix-date-tostring", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + // Normalize timezone: GMT/UTC -> "Z", otherwise flatten array to string + String tz = self.normalizeUnixTimezone(v[12]); + return self.buildDate(mode, + Integer.parseInt((String) v[14]), // year + self.parseMonthName((String) v[2]), // month + Integer.parseInt((String) v[4]), // day + Integer.parseInt((String) v[6]), // hour + Integer.parseInt((String) v[8]), // minute + Integer.parseInt((String) v[10]), // second + -1, // ms + tz); + }); + + // MMDDYY-Sep action (2-digit year) + grammar.addAction("mmddyy-sep", (val, x) -> { + Object[] v = (Object[]) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt((String) v[4]); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt((String) v[0]) - 1, + Integer.parseInt((String) v[2]), + self.parseIntOrDefault(v, 6, -1), + self.parseIntOrDefault(v, 8, -1), + self.parseIntOrDefault(v, 10, -1), + -1, + self.extractTimezone(v)); + }); + + // MMDDYY-Compact action: "011525" (2-digit year) + grammar.addAction("mmddyy-compact", (val, x) -> { + String v = (String) val; + DateParseMode mode = (DateParseMode) x.get("dateParseMode"); + int twoDigitYear = Integer.parseInt(v.substring(4, 6)); + return self.buildDate(mode, + self.convertTwoDigitYear(twoDigitYear), + Integer.parseInt(v.substring(0, 2)) - 1, + Integer.parseInt(v.substring(2, 4)), + -1, -1, -1, -1, null); + }); + } +} diff --git a/src/foam/parse/DateParser.js b/src/foam/parse/DateParser.js index 275564cdf8..c0d1b998f0 100644 --- a/src/foam/parse/DateParser.js +++ b/src/foam/parse/DateParser.js @@ -29,582 +29,537 @@ foam.CLASS({ INVALID_DATE: new Date(NaN) }, + properties: [ + { + name: 'dateParseMode', + value: 'DATETIME' + }, + { + class: 'Boolean', + name: 'strictValidation', + documentation: 'If true, throws errors for invalid dates. If false, logs warnings and returns MAX_DATE.' + } + ], + methods: [ + function buildDate(mode, year, month, day, hour, minute, second, ms, tz) { + var offset, utcTime; + switch ( mode ) { + case 'DATE': + // Always noon GMT - for parseDateString + return new Date(Date.UTC(year, month, day, 12, 0, 0, 0)); + case 'STRING': + // For parseString: date-only → noon GMT, with time → local time + if ( hour < 0 && minute < 0 && second < 0 ) { + // No time components - return noon GMT + return new Date(Date.UTC(year, month, day, 12, 0, 0, 0)); + } + // Has time - return local time + if ( tz ) { + offset = this.parseTimezone(tz); + utcTime = Date.UTC(year, month, day, + hour >= 0 ? hour : 0, minute >= 0 ? minute : 0, + second >= 0 ? second : 0, ms >= 0 ? ms : 0); + return new Date(utcTime - offset * 60000); + } + return new Date(year, month, day, + hour >= 0 ? hour : 0, minute >= 0 ? minute : 0, + second >= 0 ? second : 0, ms >= 0 ? ms : 0); + case 'DATETIME': + // Always local time with default hour 12 - for parseDateTime + if ( tz ) { + offset = this.parseTimezone(tz); + utcTime = Date.UTC(year, month, day, + hour >= 0 ? hour : 12, minute >= 0 ? minute : 0, + second >= 0 ? second : 0, ms >= 0 ? ms : 0); + return new Date(utcTime - offset * 60000); + } + return new Date(year, month, day, + hour >= 0 ? hour : 12, minute >= 0 ? minute : 0, + second >= 0 ? second : 0, ms >= 0 ? ms : 0); + case 'DATETIME_UTC': + // Always UTC - for parseDateTimeUTC + utcTime = Date.UTC(year, month, day, + hour >= 0 ? hour : 0, minute >= 0 ? minute : 0, + second >= 0 ? second : 0, ms >= 0 ? ms : 0); + if ( tz ) utcTime -= this.parseTimezone(tz) * 60000; + return new Date(utcTime); + default: + return new Date(NaN); + } + }, + + function parseIntOrDefault(v, idx, defaultVal) { + if ( ! v || v.length <= idx || v[idx] === undefined || v[idx] === null ) return defaultVal; + return parseInt(v[idx]); + }, + + function extractTimezone(v) { + if ( ! v || v.length === 0 ) return null; + var last = v[v.length - 1]; + if ( last === undefined || last === null ) return null; + if ( last === 'Z' ) return 'Z'; + if ( typeof last !== 'string' ) return this.flattenTimezone(last); + return null; + }, // YYYYMMDD with separators: YYYY-MM-DD, YYYY/MM/DD with optional time // v = [YYYY, sep, MM, sep, DD] or [YYYY, sep, MM, sep, DD, T/space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function yyyymmddsepAction(v) { - var result = { - year: parseInt(v[0]), - month: parseInt(v[2]) - 1, - day: parseInt(v[4]) - }; - - // Check if time components exist (length > 5 means time is present) - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - // If less than 3 digits, pad with zeros; if more than 3, truncate to milliseconds - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + parseInt(v[0]), + parseInt(v[2]) - 1, + parseInt(v[4]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // YYYYMMDD compact: 8 digits "20250115" or "20250115 143045" or "20250115 14:30" or "20250115 14:30:45" // v = "20250115" OR v = ["20250115", sep, HH, MM, SS] (compact time) OR v = ["20250115", sep, HH, :, MM, optional([:, SS]), optional(timezone)] (time with colons) function yyyymmddcompactAction(v) { var dateStr = typeof v === 'string' ? v : v[0]; - var result = { - year: parseInt(dateStr.substring(0, 4)), - month: parseInt(dateStr.substring(4, 6)) - 1, - day: parseInt(dateStr.substring(6, 8)) - }; + var hour = -1, minute = -1, second = -1, tz = null; - // Check if time is present (array format) if ( Array.isArray(v) && v.length > 2 ) { - // Check if this is compact time format (HHMMSS - no colons) - // v = ["20250115", sep, HH, MM, SS] where v[3] is a 2-digit string (not ':') if ( v[3] && v[3] !== ':' ) { - // Compact time format: HHMMSS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[3]); - result.second = parseInt(v[4]); + hour = parseInt(v[2]); + minute = parseInt(v[3]); + second = parseInt(v[4]); } else { - // Time with colons format: HH:MM or HH:MM:SS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[4]); - - // Check for seconds (v[5] is optional array [: SS]) + hour = parseInt(v[2]); + minute = parseInt(v[4]); if ( v[5] && Array.isArray(v[5]) ) { - result.second = parseInt(v[5][1]); - } - - // Check for timezone (last element if present) - var lastIdx = v.length - 1; - if ( v[lastIdx] !== undefined && typeof v[lastIdx] !== 'string' ) { - result.timezone = this.flattenTimezone(v[lastIdx]); - } else if ( v[lastIdx] === 'Z' ) { - result.timezone = 'Z'; + second = parseInt(v[5][1]); } + tz = this.extractTimezone(v); } } - return result; + return this.buildDate(this.dateParseMode, + parseInt(dateStr.substring(0, 4)), + parseInt(dateStr.substring(4, 6)) - 1, + parseInt(dateStr.substring(6, 8)), + hour, minute, second, -1, tz); }, // YYYYMMDDHHMMSS compact: 14 digits with time // v = [year, month, day, hour, minute, second] function yyyymmddhhmmsscompactAction(v) { - return { - year: parseInt(v[0]), - month: parseInt(v[1]) - 1, - day: parseInt(v[2]), - hour: parseInt(v[3]), - minute: parseInt(v[4]), - second: parseInt(v[5]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[0]), + parseInt(v[1]) - 1, + parseInt(v[2]), + parseInt(v[3]), + parseInt(v[4]), + parseInt(v[5]), + -1, null); }, // MMDDYYYY with separators: MM-DD-YYYY, MM/DD/YYYY with optional time // v = [MM, sep, DD, sep, YYYY] or [MM, sep, DD, sep, YYYY, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function mmddyyyysepAction(v) { - var result = { - year: parseInt(v[4]), - month: parseInt(v[0]) - 1, - day: parseInt(v[2]) - }; - - // Check if time components exist - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + parseInt(v[4]), + parseInt(v[0]) - 1, + parseInt(v[2]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // MMDDYYYY compact: 8 digits "01152025" or "01152025 143045" or "01152025 14:30" or "01152025 14:30:45" // v = "01152025" OR v = ["01152025", sep, HH, MM, SS] (compact time) OR v = ["01152025", sep, HH, :, MM, optional([:, SS]), optional(timezone)] (time with colons) function mmddyyyycompactAction(v) { var dateStr = typeof v === 'string' ? v : v[0]; - var result = { - year: parseInt(dateStr.substring(4, 8)), - month: parseInt(dateStr.substring(0, 2)) - 1, - day: parseInt(dateStr.substring(2, 4)) - }; + var hour = -1, minute = -1, second = -1, tz = null; - // Check if time is present (array format) if ( Array.isArray(v) && v.length > 2 ) { - // Check if this is compact time format (HHMMSS - no colons) if ( v[3] && v[3] !== ':' ) { - // Compact time format: HHMMSS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[3]); - result.second = parseInt(v[4]); + hour = parseInt(v[2]); + minute = parseInt(v[3]); + second = parseInt(v[4]); } else { - // Time with colons format: HH:MM or HH:MM:SS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[4]); - - // Check for seconds (v[5] is optional array [: SS]) + hour = parseInt(v[2]); + minute = parseInt(v[4]); if ( v[5] && Array.isArray(v[5]) ) { - result.second = parseInt(v[5][1]); - } - - // Check for timezone (last element if present) - var lastIdx = v.length - 1; - if ( v[lastIdx] !== undefined && typeof v[lastIdx] !== 'string' ) { - result.timezone = this.flattenTimezone(v[lastIdx]); - } else if ( v[lastIdx] === 'Z' ) { - result.timezone = 'Z'; + second = parseInt(v[5][1]); } + tz = this.extractTimezone(v); } } - return result; + return this.buildDate(this.dateParseMode, + parseInt(dateStr.substring(4, 8)), + parseInt(dateStr.substring(0, 2)) - 1, + parseInt(dateStr.substring(2, 4)), + hour, minute, second, -1, tz); }, // YYMMDD with separators: YY-MM-DD, YY/MM/DD with optional time and timezone // v = [YY, sep, MM, sep, DD] or [YY, sep, MM, sep, DD, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function yymmddsepAction(v) { var twoDigitYear = parseInt(v[0]); - var result = { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v[2]) - 1, - day: parseInt(v[4]) - }; - - // Check if time components are present - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - result.minute = parseInt(v[8]); - - // Check if seconds are present (v[10] exists and is not timezone) - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v[2]) - 1, + parseInt(v[4]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // YYMMDD compact: 6 digits "250115" // v = "250115" function yymmddcompactAction(v) { var twoDigitYear = parseInt(v.substring(0, 2)); - return { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v.substring(2, 4)) - 1, - day: parseInt(v.substring(4, 6)) - }; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v.substring(2, 4)) - 1, + parseInt(v.substring(4, 6)), + -1, -1, -1, -1, null); }, // MMDDYY with separators: MM-DD-YY, MM/DD/YY with optional time // v = [MM, sep, DD, sep, YY] or [MM, sep, DD, sep, YY, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function mmddyysepAction(v) { var twoDigitYear = parseInt(v[4]); - var result = { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v[0]) - 1, - day: parseInt(v[2]) - }; - - // Check if time components exist - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v[0]) - 1, + parseInt(v[2]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // MMDDYY compact: 6 digits "011525" // v = "011525" function mmddyycompactAction(v) { var twoDigitYear = parseInt(v.substring(4, 6)); - return { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v.substring(0, 2)) - 1, - day: parseInt(v.substring(2, 4)) - }; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v.substring(0, 2)) - 1, + parseInt(v.substring(2, 4)), + -1, -1, -1, -1, null); }, // DDMMYYYY with separators: DD-MM-YYYY, DD/MM/YYYY with optional time // v = [DD, sep, MM, sep, YYYY] or [DD, sep, MM, sep, YYYY, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function ddmmyyyysepAction(v) { - var result = { - year: parseInt(v[4]), - month: parseInt(v[2]) - 1, - day: parseInt(v[0]) - }; - - // Check if time components exist - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + parseInt(v[4]), + parseInt(v[2]) - 1, + parseInt(v[0]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // DDMMYYYY compact: 8 digits "15012025" or "15012025 143045" or "15012025 14:30" or "15012025 14:30:45" // v = "15012025" OR v = ["15012025", sep, HH, MM, SS] (compact time) OR v = ["15012025", sep, HH, :, MM, optional([:, SS]), optional(timezone)] (time with colons) function ddmmyyyycompactAction(v) { var dateStr = typeof v === 'string' ? v : v[0]; - var result = { - year: parseInt(dateStr.substring(4, 8)), - month: parseInt(dateStr.substring(2, 4)) - 1, - day: parseInt(dateStr.substring(0, 2)) - }; + var hour = -1, minute = -1, second = -1, tz = null; - // Check if time is present (array format) if ( Array.isArray(v) && v.length > 2 ) { - // Check if this is compact time format (HHMMSS - no colons) if ( v[3] && v[3] !== ':' ) { - // Compact time format: HHMMSS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[3]); - result.second = parseInt(v[4]); + hour = parseInt(v[2]); + minute = parseInt(v[3]); + second = parseInt(v[4]); } else { - // Time with colons format: HH:MM or HH:MM:SS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[4]); - - // Check for seconds (v[5] is optional array [: SS]) + hour = parseInt(v[2]); + minute = parseInt(v[4]); if ( v[5] && Array.isArray(v[5]) ) { - result.second = parseInt(v[5][1]); - } - - // Check for timezone (last element if present) - var lastIdx = v.length - 1; - if ( v[lastIdx] !== undefined && typeof v[lastIdx] !== 'string' ) { - result.timezone = this.flattenTimezone(v[lastIdx]); - } else if ( v[lastIdx] === 'Z' ) { - result.timezone = 'Z'; + second = parseInt(v[5][1]); } + tz = this.extractTimezone(v); } } - return result; + return this.buildDate(this.dateParseMode, + parseInt(dateStr.substring(4, 8)), + parseInt(dateStr.substring(2, 4)) - 1, + parseInt(dateStr.substring(0, 2)), + hour, minute, second, -1, tz); }, // DDMMYY with separators: DD-MM-YY, DD/MM/YY with optional time // v = [DD, sep, MM, sep, YY] or [DD, sep, MM, sep, YY, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function ddmmyysepAction(v) { var twoDigitYear = parseInt(v[4]); - var result = { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v[2]) - 1, - day: parseInt(v[0]) - }; - - // Check if time components exist - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v[2]) - 1, + parseInt(v[0]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // DDMMYY compact: 6 digits "150125" // v = "150125" function ddmmyycompactAction(v) { var twoDigitYear = parseInt(v.substring(4, 6)); - return { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v.substring(2, 4)) - 1, - day: parseInt(v.substring(0, 2)) - }; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v.substring(2, 4)) - 1, + parseInt(v.substring(0, 2)), + -1, -1, -1, -1, null); }, // YYYYDDMM with separators: YYYY-DD-MM, YYYY/DD/MM with optional time // v = [YYYY, sep, DD, sep, MM] or [YYYY, sep, DD, sep, MM, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function yyyyddmmsepAction(v) { - var result = { - year: parseInt(v[0]), - month: parseInt(v[4]) - 1, - day: parseInt(v[2]) - }; - - // Check if time components exist - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + parseInt(v[0]), + parseInt(v[4]) - 1, + parseInt(v[2]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // YYYYDDMM compact: 8 digits "20251501" or "20251501 143045" or "20251501 14:30" or "20251501 14:30:45" // v = "20251501" OR v = ["20251501", sep, HH, MM, SS] (compact time) OR v = ["20251501", sep, HH, :, MM, optional([:, SS]), optional(timezone)] (time with colons) function yyyyddmmcompactAction(v) { var dateStr = typeof v === 'string' ? v : v[0]; - var result = { - year: parseInt(dateStr.substring(0, 4)), - month: parseInt(dateStr.substring(6, 8)) - 1, - day: parseInt(dateStr.substring(4, 6)) - }; + var hour = -1, minute = -1, second = -1, tz = null; - // Check if time is present (array format) if ( Array.isArray(v) && v.length > 2 ) { - // Check if this is compact time format (HHMMSS - no colons) if ( v[3] && v[3] !== ':' ) { - // Compact time format: HHMMSS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[3]); - result.second = parseInt(v[4]); + hour = parseInt(v[2]); + minute = parseInt(v[3]); + second = parseInt(v[4]); } else { - // Time with colons format: HH:MM or HH:MM:SS - result.hour = parseInt(v[2]); - result.minute = parseInt(v[4]); - - // Check for seconds (v[5] is optional array [: SS]) + hour = parseInt(v[2]); + minute = parseInt(v[4]); if ( v[5] && Array.isArray(v[5]) ) { - result.second = parseInt(v[5][1]); - } - - // Check for timezone (last element if present) - var lastIdx = v.length - 1; - if ( v[lastIdx] !== undefined && typeof v[lastIdx] !== 'string' ) { - result.timezone = this.flattenTimezone(v[lastIdx]); - } else if ( v[lastIdx] === 'Z' ) { - result.timezone = 'Z'; + second = parseInt(v[5][1]); } + tz = this.extractTimezone(v); } } - return result; + return this.buildDate(this.dateParseMode, + parseInt(dateStr.substring(0, 4)), + parseInt(dateStr.substring(6, 8)) - 1, + parseInt(dateStr.substring(4, 6)), + hour, minute, second, -1, tz); }, // YYDDMM with separators: YY-DD-MM, YY/DD/MM with optional time // v = [YY, sep, DD, sep, MM] or [YY, sep, DD, sep, MM, space, HH, :, MM, :, SS, ., fractionalSecs, timezone] function yyddmmsepAction(v) { var twoDigitYear = parseInt(v[0]); - var result = { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v[4]) - 1, - day: parseInt(v[2]) - }; - - // Check if time components exist - if ( v.length > 5 && v[6] !== undefined ) { - result.hour = parseInt(v[6]); - if ( v[8] !== undefined ) result.minute = parseInt(v[8]); - if ( v[10] !== undefined ) result.second = parseInt(v[10]); - - // Handle fractional seconds (1-6 digits) - normalize to milliseconds (3 digits) - if ( v[12] !== undefined ) { - var fracStr = v[12]; - if ( fracStr.length <= 3 ) { - result.millisecond = parseInt(fracStr.padEnd(3, '0')); - } else { - result.millisecond = parseInt(fracStr.substring(0, 3)); - } - } - - // Check for timezone (last element if present) - if ( v[v.length - 1] !== undefined && typeof v[v.length - 1] !== 'string' ) { - result.timezone = this.flattenTimezone(v[v.length - 1]); - } else if ( v[v.length - 1] === 'Z' ) { - result.timezone = 'Z'; + var ms = -1; + if ( v[12] !== undefined ) { + var fracStr = v[12]; + if ( fracStr.length <= 3 ) { + ms = parseInt(fracStr.padEnd(3, '0')); + } else { + ms = parseInt(fracStr.substring(0, 3)); } } - - return result; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v[4]) - 1, + parseInt(v[2]), + this.parseIntOrDefault(v, 6, -1), + this.parseIntOrDefault(v, 8, -1), + this.parseIntOrDefault(v, 10, -1), + ms, + this.extractTimezone(v)); }, // YYDDMM compact: 6 digits "251501" // v = "251501" function yyddmmcompactAction(v) { var twoDigitYear = parseInt(v.substring(0, 2)); - return { - year: this.convertTwoDigitYear(twoDigitYear), - month: parseInt(v.substring(4, 6)) - 1, - day: parseInt(v.substring(2, 4)) - }; + return this.buildDate(this.dateParseMode, + this.convertTwoDigitYear(twoDigitYear), + parseInt(v.substring(4, 6)) - 1, + parseInt(v.substring(2, 4)), + -1, -1, -1, -1, null); }, // DDMMMYYYY with separators: DD-MMM-YYYY, DD/MMM/YYYY // v = [DD, sep, MMM, sep, YYYY] function ddmmmyyyysepAction(v) { - return { - year: parseInt(v[4]), - month: this.parseMonthName(v[2]), - day: parseInt(v[0]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[4]), + this.parseMonthName(v[2]), + parseInt(v[0]), + -1, -1, -1, -1, null); }, // DDMMMYYYY compact: DDMMMYYYY "31JAN2025" // v = [DD, MMM, YYYY] function ddmmmyyyycompactAction(v) { - return { - year: parseInt(v[2]), - month: this.parseMonthName(v[1]), - day: parseInt(v[0]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[2]), + this.parseMonthName(v[1]), + parseInt(v[0]), + -1, -1, -1, -1, null); }, // MMM dd yyyy with spaces: "Jan 02 2025" // v = [MMM, ' ', DD, ' ', YYYY] function mmmddyyyyspaceAction(v) { - return { - year: parseInt(v[4]), - month: this.parseMonthName(v[0]), - day: parseInt(v[2]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[4]), + this.parseMonthName(v[0]), + parseInt(v[2]), + -1, -1, -1, -1, null); }, // DD MMM YYYY with spaces: "15 JAN 2025" // v = [DD, ' ', MMM, ' ', YYYY] function ddmmmyyyyspaceAction(v) { - return { - year: parseInt(v[4]), - month: this.parseMonthName(v[2]), - day: parseInt(v[0]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[4]), + this.parseMonthName(v[2]), + parseInt(v[0]), + -1, -1, -1, -1, null); + }, + + // Unix/Java Date.toString() format: DDD MMM DD HH:MM:SS TZ YYYY + // e.g., "Tue Apr 01 05:17:59 GMT 2025" + // v = [DDD, ' ', MMM, ' ', DD, ' ', HH, ':', MM, ':', SS, ' ', TZ, ' ', YYYY] + function unixdatetostringAction(v) { + // v[0] = day name (ignored) + // v[2] = month name + // v[4] = day + // v[6] = hour + // v[8] = minute + // v[10] = second + // v[12] = timezone (GMT, UTC, or +/-offset) + // v[14] = year + var tz = this.normalizeUnixTimezone(v[12]); + return this.buildDate(this.dateParseMode, + parseInt(v[14]), + this.parseMonthName(v[2]), + parseInt(v[4]), + parseInt(v[6]), + parseInt(v[8]), + parseInt(v[10]), + -1, + tz); + }, + + // Normalize Unix timezone format to standard format + // GMT/UTC -> 'Z', +0530 -> '+05:30', etc. + function normalizeUnixTimezone(tz) { + if ( ! tz ) return null; + // Handle string values (GMT, UTC) + if ( typeof tz === 'string' ) { + var tzUpper = tz.toUpperCase(); + if ( tzUpper === 'GMT' || tzUpper === 'UTC' ) { + return 'Z'; + } + return tz; + } + // Handle array values from seq() - offset format like ['+', ['0', '5', '3', '0']] + if ( Array.isArray(tz) ) { + return this.flattenTimezone(tz); + } + return null; }, // YYYYDDMMM with separators: YYYY-DD-MMM, YYYY/DD/MMM // v = [YYYY, sep, DD, sep, MMM] function yyyyddmmmsepAction(v) { - return { - year: parseInt(v[0]), - month: this.parseMonthName(v[4]), - day: parseInt(v[2]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[0]), + this.parseMonthName(v[4]), + parseInt(v[2]), + -1, -1, -1, -1, null); }, // YYYYDDMMM compact: YYYYDDMMM "202531JAN" // v = [YYYY, DD, MMM] function yyyyddmmmcompactAction(v) { - return { - year: parseInt(v[0]), - month: this.parseMonthName(v[2]), - day: parseInt(v[1]) - }; + return this.buildDate(this.dateParseMode, + parseInt(v[0]), + this.parseMonthName(v[2]), + parseInt(v[1]), + -1, -1, -1, -1, null); }, // Timestamp actions - convert timestamp strings directly to Date objects @@ -668,24 +623,26 @@ foam.CLASS({ function validateDate(date, str) { // Check if date is NaN if ( isNaN(date.getTime()) ) { + if ( this.strictValidation ) { + throw new Error('Invalid date: "' + str + '"'); + } date = foam.Date.MAX_DATE; console.warn("Invalid date: " + str + "; assuming " + date.toISOString() + "."); return date; } - - // Allow JavaScript's native date normalization (e.g., 2025-13-01 -> 2026-01-01) return date; }, function validateDateUTC(date, str) { // Check if date is NaN if ( isNaN(date.getTime()) ) { + if ( this.strictValidation ) { + throw new Error('Invalid date: "' + str + '"'); + } date = foam.Date.MAX_DATE; console.warn("Invalid date: " + str + "; assuming " + date.toISOString() + "."); return date; } - - // Allow JavaScript's native date normalization (e.g., 2025-13-01 -> 2026-01-01) return date; }, @@ -707,29 +664,40 @@ foam.CLASS({ 'MAY': 4, 'JUN': 5, 'JUL': 6, 'AUG': 7, 'SEP': 8, 'OCT': 9, 'NOV': 10, 'DEC': 11 }; - return months[month] !== undefined ? months[month] : 0; + if ( months[month] === undefined ) { + if ( this.strictValidation ) { + throw new Error('Invalid month name: "' + monthName + '"'); + } + console.warn('Invalid month name: "' + monthName + '"; assuming January.'); + return 0; + } + return months[month]; }, function parseString(str, opt_name) { - // Handle null, undefined, or empty string if ( ! str || str.trim() === '' ) { - return this.validateDate(this.INVALID_DATE, str); + if ( this.strictValidation ) { + throw new Error('Unsupported Date format: empty or null string'); + } + console.warn('Invalid date: empty or null string; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - - // Trim input to remove leading/trailing whitespace str = str.trim(); + this.dateParseMode = 'STRING'; // Use parse() to get position information var parseResult = this.parse(this.StringPStream.create({ str: str }), this, opt_name); - if ( ! parseResult ) { - // Unparseable format - return MAX_DATE - return this.validateDate(this.INVALID_DATE, str); + if ( ! parseResult || ! parseResult.value ) { + if ( this.strictValidation ) { + throw new Error('Unsupported Date format: ' + str); + } + console.warn('Invalid date: "' + str + '"; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - // Check if entire string was consumed - warn but still return parsed value if ( parseResult.pos < str.length ) { - console.warn('DateParser: Partial parse in parseString. Input:', str, 'Consumed up to position:', parseResult.pos, 'Remaining:', str.substring(parseResult.pos)); + console.warn('DateParser: Partial parse in parseString. Input:', str); } var result = parseResult.value; @@ -766,13 +734,15 @@ foam.CLASS({ }, function parseDateString(str, opt_name) { - // Handle null, undefined, or empty string if ( ! str || str.trim() === '' ) { - return this.validateDate(this.INVALID_DATE, str); + if ( this.strictValidation ) { + throw new Error('Unsupported Date format: empty or null string'); + } + console.warn('Invalid date: empty or null string; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - - // Trim input to remove leading/trailing whitespace str = str.trim(); + this.dateParseMode = 'DATE'; // Use parse() to get position information var parseResult = this.parse(this.StringPStream.create({ str: str }), this, opt_name); @@ -799,30 +769,31 @@ foam.CLASS({ return this.validateDate(result, str); } - // Always return date at noon UTC, ignoring time even if present - var ret = new Date(Date.UTC(result.year, result.month, result.day, 12, 0, 0, 0)); - - return this.validateDate(ret, str); + return parseResult.value; }, function parseDateTime(str, opt_name) { - // Handle null, undefined, or empty string if ( ! str || str.trim() === '' ) { - return this.validateDate(this.INVALID_DATE, str); + if ( this.strictValidation ) { + throw new Error('Unsupported DateTime format: empty or null string'); + } + console.warn('Invalid datetime: empty or null string; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - - // Trim input to remove leading/trailing whitespace str = str.trim(); + this.dateParseMode = 'DATETIME'; // Use parse() instead of parseString() to get position information var parseResult = this.parse(this.StringPStream.create({ str: str }), this, opt_name); - if ( ! parseResult ) { - // Unparseable format - return MAX_DATE - return this.validateDate(this.INVALID_DATE, str); + if ( ! parseResult || ! parseResult.value ) { + if ( this.strictValidation ) { + throw new Error('Unsupported DateTime format: ' + str); + } + console.warn('Invalid datetime: "' + str + '"; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - // Check if entire string was consumed if ( parseResult.pos < str.length ) { // Partial parse - remaining characters indicate invalid format console.warn('DateParser: Partial parse detected. Input:', str,'opt_name:', opt_name, 'Consumed up to position:', parseResult.pos, 'Remaining:', str.substring(parseResult.pos)); @@ -888,23 +859,27 @@ foam.CLASS({ }, function parseDateTimeUTC(str, opt_name) { - // Handle null, undefined, or empty string if ( ! str || str.trim() === '' ) { - return this.validateDateUTC(this.INVALID_DATE, str); + if ( this.strictValidation ) { + throw new Error('Unsupported DateTime format: empty or null string'); + } + console.warn('Invalid datetime: empty or null string; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - - // Trim input to remove leading/trailing whitespace str = str.trim(); + this.dateParseMode = 'DATETIME_UTC'; // Use parse() instead of parseString() to get position information var parseResult = this.parse(this.StringPStream.create({ str: str }), this, opt_name); - if ( ! parseResult ) { - // Unparseable format - return MAX_DATE - return this.validateDateUTC(this.INVALID_DATE, str); + if ( ! parseResult || ! parseResult.value ) { + if ( this.strictValidation ) { + throw new Error('Unsupported DateTime format: ' + str); + } + console.warn('Invalid datetime: "' + str + '"; assuming MAX_DATE.'); + return foam.Date.MAX_DATE; } - // Check if entire string was consumed if ( parseResult.pos < str.length ) { // Partial parse - remaining characters indicate invalid format console.warn('DateParser: Partial parse detected for UTC. Input:', str, 'opt_name:', opt_name, 'Consumed up to position:', parseResult.pos, 'Remaining:', str.substring(parseResult.pos)); @@ -929,44 +904,8 @@ foam.CLASS({ if ( result.hour !== undefined && (result.hour < 0 || result.hour > 23) ) { return this.validateDateUTC(this.INVALID_DATE, str); } - if ( result.minute !== undefined && (result.minute < 0 || result.minute > 59) ) { - return this.validateDateUTC(this.INVALID_DATE, str); - } - if ( result.second !== undefined && (result.second < 0 || result.second > 59) ) { - return this.validateDateUTC(this.INVALID_DATE, str); - } - var ret; - if ( result.timezone ) { - // Timezone present - convert to UTC - var offset = this.parseTimezone(result.timezone); - var utcTime = Date.UTC( - result.year, - result.month, - result.day, - result.hour !== undefined ? result.hour : 0, - result.minute !== undefined ? result.minute : 0, - result.second !== undefined ? result.second : 0, - result.millisecond !== undefined ? result.millisecond : 0 - ); - // Subtract offset to convert to UTC (if timezone is +05:00, we subtract 5 hours) - utcTime -= offset * 60000; - ret = new Date(utcTime); - // Don't validate date parts - timezone conversion is expected to change the date - return this.validateDateUTC(ret, str); - } else { - // No timezone - use UTC time as-is - ret = new Date(Date.UTC( - result.year, - result.month, - result.day, - result.hour !== undefined ? result.hour : 0, - result.minute !== undefined ? result.minute : 0, - result.second !== undefined ? result.second : 0, - result.millisecond !== undefined ? result.millisecond : 0 - )); - return this.validateDateUTC(ret, str); - } + return parseResult.value; } ] }); diff --git a/src/foam/parse/test/DateParserJavaTest.js b/src/foam/parse/test/DateParserJavaTest.js new file mode 100644 index 0000000000..0dbcc1fc51 --- /dev/null +++ b/src/foam/parse/test/DateParserJavaTest.js @@ -0,0 +1,1199 @@ +/** + * @license + * Copyright 2025 The FOAM Authors. All Rights Reserved. + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +foam.CLASS({ + package: 'foam.parse.test', + name: 'DateParserJavaTest', + extends: 'foam.core.test.Test', + + javaImports: [ + 'foam.parse.DateParser', + 'java.util.Calendar', + 'java.util.Date', + 'java.util.TimeZone' + ], + + documentation: 'Comprehensive Java tests for DateParser covering all formats', + + methods: [ + { + name: 'runTest', + javaCode: ` + // YYYYMMDD Format Tests + DateParserTest_YYYYMMDD_Separated(); + DateParserTest_YYYYMMDD_Compact(); + DateParserTest_YYYYMMDD_WithTime(); + DateParserTest_YYYYMMDD_WithTimezone(); + + // MMDDYYYY Format Tests + DateParserTest_MMDDYYYY_Separated(); + DateParserTest_MMDDYYYY_Compact(); + DateParserTest_MMDDYYYY_WithTime(); + + // YYMMDD Format Tests + DateParserTest_YYMMDD_Separated(); + DateParserTest_YYMMDD_Compact(); + DateParserTest_YYMMDD_TwoDigitYearPivot(); + + // DDMMYYYY Format Tests (via opt_name) + DateParserTest_DDMMYYYY_Separated(); + DateParserTest_DDMMYYYY_Compact(); + DateParserTest_DDMMYYYY_WithTime(); + + // YYYYDDMM Format Tests (via opt_name) + DateParserTest_YYYYDDMM_Separated(); + DateParserTest_YYYYDDMM_Compact(); + + // Month Name Format Tests + DateParserTest_DDMMMYYYY_Separated(); + DateParserTest_DDMMMYYYY_Compact(); + DateParserTest_YYYYDDMMM_Separated(); + DateParserTest_YYYYDDMMM_Compact(); + + // Parsing Method Tests + DateParserTest_parseDateString(); + DateParserTest_parseDateTime(); + DateParserTest_parseDateTimeUTC(); + + // Timezone Tests + DateParserTest_Timezone_Z(); + DateParserTest_Timezone_Positive(); + DateParserTest_Timezone_Negative(); + DateParserTest_Timezone_Formats(); + + // Edge Cases and Validation + DateParserTest_LeapYear(); + DateParserTest_InvalidDates(); + DateParserTest_PartialParse(); + + // Strict Validation Mode Tests + DateParserTest_StrictValidation_ThrowsForInvalid(); + DateParserTest_StrictValidation_ValidDatesWork(); + DateParserTest_LenientValidation_ReturnsMaxDate(); + DateParserTest_LenientValidation_ValidDatesWork(); + + // New Format Tests (parity with JavaScript) + DateParserTest_Timestamps(); + DateParserTest_SingleDigitMonthDay(); + DateParserTest_SpaceSeparatedMonthNames(); + DateParserTest_MMDDYY_Format(); + DateParserTest_FractionalSeconds(); + + // Unix/Java Date.toString() format tests + DateParserTest_UnixDateToString(); + ` + }, + + // ========== YYYYMMDD Format Tests ========== + + { + name: 'DateParserTest_YYYYMMDD_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 2025-01-15 + Date date1 = parser.parseString("2025-01-15"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYMMDD-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYMMDD-Sep: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYMMDD-Sep: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 12, "YYYYMMDD-Sep: hour 12 (noon)"); + + // Test 2025/01/15 + Date date2 = parser.parseString("2025/01/15"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "YYYYMMDD-Sep (slash): year 2025"); + test(cal2.get(Calendar.MONTH) == 0, "YYYYMMDD-Sep (slash): month 0"); + + // Test 2024-12-31 + Date date3 = parser.parseString("2024-12-31"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2024, "YYYYMMDD-Sep: year 2024"); + test(cal3.get(Calendar.MONTH) == 11, "YYYYMMDD-Sep: month 11 (Dec)"); + test(cal3.get(Calendar.DAY_OF_MONTH) == 31, "YYYYMMDD-Sep: day 31"); + ` + }, + + { + name: 'DateParserTest_YYYYMMDD_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 20250115 + Date date1 = parser.parseString("20250115"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYMMDD-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYMMDD-Compact: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYMMDD-Compact: day 15"); + + // Test 20241231 + Date date2 = parser.parseString("20241231"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "YYYYMMDD-Compact: year 2024"); + test(cal2.get(Calendar.MONTH) == 11, "YYYYMMDD-Compact: month 11 (Dec)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 31, "YYYYMMDD-Compact: day 31"); + + // Test 19990101 + Date date3 = parser.parseString("19990101"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 1999, "YYYYMMDD-Compact: year 1999"); + test(cal3.get(Calendar.MONTH) == 0, "YYYYMMDD-Compact: month 0 (Jan)"); + test(cal3.get(Calendar.DAY_OF_MONTH) == 1, "YYYYMMDD-Compact: day 1"); + ` + }, + + { + name: 'DateParserTest_YYYYMMDD_WithTime', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 2025-01-15T14:30:45 + Date date1 = parser.parseDateTime("2025-01-15T14:30:45"); + Calendar cal1 = Calendar.getInstance(); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYMMDD with time: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYMMDD with time: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYMMDD with time: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "YYYYMMDD with time: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "YYYYMMDD with time: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "YYYYMMDD with time: second 45"); + + // Test 2025-01-15 09:15 (space separator) + Date date2 = parser.parseDateTime("2025-01-15 09:15"); + Calendar cal2 = Calendar.getInstance(); + cal2.setTime(date2); + test(cal2.get(Calendar.HOUR_OF_DAY) == 9, "YYYYMMDD with time (space): hour 9"); + test(cal2.get(Calendar.MINUTE) == 15, "YYYYMMDD with time (space): minute 15"); + ` + }, + + { + name: 'DateParserTest_YYYYMMDD_WithTimezone', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 2025-01-15T14:30:45Z + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45Z"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYMMDD with Z: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYMMDD with Z: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYMMDD with Z: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "YYYYMMDD with Z: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "YYYYMMDD with Z: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "YYYYMMDD with Z: second 45"); + ` + }, + + // ========== MMDDYYYY Format Tests ========== + + { + name: 'DateParserTest_MMDDYYYY_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 01/15/2025 + Date date1 = parser.parseString("01/15/2025"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "MMDDYYYY-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "MMDDYYYY-Sep: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "MMDDYYYY-Sep: day 15"); + + // Test 12/31/2024 + Date date2 = parser.parseString("12/31/2024"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "MMDDYYYY-Sep: year 2024"); + test(cal2.get(Calendar.MONTH) == 11, "MMDDYYYY-Sep: month 11 (Dec)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 31, "MMDDYYYY-Sep: day 31"); + ` + }, + + { + name: 'DateParserTest_MMDDYYYY_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 01152025 + Date date1 = parser.parseString("01152025"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "MMDDYYYY-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "MMDDYYYY-Compact: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "MMDDYYYY-Compact: day 15"); + + // Test 12312024 + Date date2 = parser.parseString("12312024"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "MMDDYYYY-Compact: year 2024"); + test(cal2.get(Calendar.MONTH) == 11, "MMDDYYYY-Compact: month 11 (Dec)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 31, "MMDDYYYY-Compact: day 31"); + ` + }, + + { + name: 'DateParserTest_MMDDYYYY_WithTime', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 01/15/2025 14:30:45 + Date date1 = parser.parseDateTime("01/15/2025 14:30:45"); + Calendar cal1 = Calendar.getInstance(); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "MMDDYYYY with time: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "MMDDYYYY with time: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "MMDDYYYY with time: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "MMDDYYYY with time: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "MMDDYYYY with time: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "MMDDYYYY with time: second 45"); + ` + }, + + // ========== YYMMDD Format Tests ========== + + { + name: 'DateParserTest_YYMMDD_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 25/01/15 - YYMMDD requires opt_name because it's ambiguous with MMDDYY + Date date1 = parser.parseString("25/01/15", "yymmdd"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYMMDD-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYMMDD-Sep: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYMMDD-Sep: day 15"); + + // Test 00/02/29 (leap year 2000) + Date date2 = parser.parseString("00/02/29", "yymmdd"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2000, "YYMMDD-Sep: year 2000"); + test(cal2.get(Calendar.MONTH) == 1, "YYMMDD-Sep: month 1 (Feb)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 29, "YYMMDD-Sep: day 29"); + ` + }, + + { + name: 'DateParserTest_YYMMDD_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 250115 - YYMMDD requires opt_name because it's ambiguous with MMDDYY + Date date1 = parser.parseString("250115", "yymmdd"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYMMDD-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYMMDD-Compact: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYMMDD-Compact: day 15"); + + // Test 000229 (leap year 2000) + Date date2 = parser.parseString("000229", "yymmdd"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2000, "YYMMDD-Compact: year 2000"); + test(cal2.get(Calendar.MONTH) == 1, "YYMMDD-Compact: month 1 (Feb)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 29, "YYMMDD-Compact: day 29"); + ` + }, + + { + name: 'DateParserTest_YYMMDD_TwoDigitYearPivot', + javaCode: ` + DateParser parser = new DateParser(); + + // Test pivot at 50: 00-49 => 2000-2049, 50-99 => 1950-1999 + // YYMMDD requires opt_name because it's ambiguous with MMDDYY + + // Test 49/12/31 (should be 2049) + Date date1 = parser.parseString("49/12/31", "yymmdd"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2049, "YYMMDD pivot: 49 => 2049"); + + // Test 50/01/01 (should be 1950) + Date date2 = parser.parseString("50/01/01", "yymmdd"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 1950, "YYMMDD pivot: 50 => 1950"); + + // Test 99/12/31 (should be 1999) + Date date3 = parser.parseString("99/12/31", "yymmdd"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 1999, "YYMMDD pivot: 99 => 1999"); + ` + }, + + // ========== DDMMYYYY Format Tests ========== + + { + name: 'DateParserTest_DDMMYYYY_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 15/01/2025 (with opt_name='ddmmyyyy') + Date date1 = parser.parseString("15/01/2025", "ddmmyyyy"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "DDMMYYYY-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "DDMMYYYY-Sep: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "DDMMYYYY-Sep: day 15"); + + // Test 31/12/2024 + Date date2 = parser.parseString("31/12/2024", "ddmmyyyy"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "DDMMYYYY-Sep: year 2024"); + test(cal2.get(Calendar.MONTH) == 11, "DDMMYYYY-Sep: month 11 (Dec)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 31, "DDMMYYYY-Sep: day 31"); + ` + }, + + { + name: 'DateParserTest_DDMMYYYY_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 15012025 (with opt_name='ddmmyyyy') + Date date1 = parser.parseString("15012025", "ddmmyyyy"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "DDMMYYYY-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "DDMMYYYY-Compact: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "DDMMYYYY-Compact: day 15"); + + // Test 31122024 + Date date2 = parser.parseString("31122024", "ddmmyyyy"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "DDMMYYYY-Compact: year 2024"); + test(cal2.get(Calendar.MONTH) == 11, "DDMMYYYY-Compact: month 11 (Dec)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 31, "DDMMYYYY-Compact: day 31"); + ` + }, + + { + name: 'DateParserTest_DDMMYYYY_WithTime', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 15/01/2025 14:30:45 (with opt_name='ddmmyyyy') + Date date1 = parser.parseDateTime("15/01/2025 14:30:45", "ddmmyyyy"); + Calendar cal1 = Calendar.getInstance(); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "DDMMYYYY with time: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "DDMMYYYY with time: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "DDMMYYYY with time: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "DDMMYYYY with time: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "DDMMYYYY with time: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "DDMMYYYY with time: second 45"); + ` + }, + + // ========== YYYYDDMM Format Tests ========== + + { + name: 'DateParserTest_YYYYDDMM_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 2025/15/01 (with opt_name='yyyyddmm') + Date date1 = parser.parseString("2025/15/01", "yyyyddmm"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYDDMM-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYDDMM-Sep: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYDDMM-Sep: day 15"); + ` + }, + + { + name: 'DateParserTest_YYYYDDMM_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 20251501 (with opt_name='yyyyddmm') + Date date1 = parser.parseString("20251501", "yyyyddmm"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYDDMM-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYDDMM-Compact: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYDDMM-Compact: day 15"); + ` + }, + + // ========== Month Name Format Tests ========== + + { + name: 'DateParserTest_DDMMMYYYY_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 15-JAN-2025 + Date date1 = parser.parseString("15-JAN-2025"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "DDMMMYYYY-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "DDMMMYYYY-Sep: month 0 (JAN)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "DDMMMYYYY-Sep: day 15"); + + // Test 31/DEC/2024 + Date date2 = parser.parseString("31/DEC/2024"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "DDMMMYYYY-Sep: year 2024"); + test(cal2.get(Calendar.MONTH) == 11, "DDMMMYYYY-Sep: month 11 (DEC)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 31, "DDMMMYYYY-Sep: day 31"); + ` + }, + + { + name: 'DateParserTest_DDMMMYYYY_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 15JAN2025 + Date date1 = parser.parseString("15JAN2025"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "DDMMMYYYY-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "DDMMMYYYY-Compact: month 0 (JAN)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "DDMMMYYYY-Compact: day 15"); + ` + }, + + { + name: 'DateParserTest_YYYYDDMMM_Separated', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 2025-15-JAN + Date date1 = parser.parseString("2025-15-JAN"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYDDMMM-Sep: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYDDMMM-Sep: month 0 (JAN)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYDDMMM-Sep: day 15"); + ` + }, + + { + name: 'DateParserTest_YYYYDDMMM_Compact', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 202515JAN + Date date1 = parser.parseString("202515JAN"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYYDDMMM-Compact: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYYDDMMM-Compact: month 0 (JAN)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "YYYYDDMMM-Compact: day 15"); + ` + }, + + // ========== Parsing Method Tests ========== + + { + name: 'DateParserTest_parseDateString', + javaCode: ` + DateParser parser = new DateParser(); + + // parseDateString should return date at noon GMT + Date date1 = parser.parseDateString("2025-01-15"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "parseDateString: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "parseDateString: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "parseDateString: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 12, "parseDateString: hour 12 (noon)"); + test(cal1.get(Calendar.MINUTE) == 0, "parseDateString: minute 0"); + test(cal1.get(Calendar.SECOND) == 0, "parseDateString: second 0"); + + // Even if time is present, parseDateString ignores it + Date date2 = parser.parseDateString("2025-01-15T14:30:45"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.HOUR_OF_DAY) == 12, "parseDateString with time: hour 12 (ignores time)"); + ` + }, + + { + name: 'DateParserTest_parseDateTime', + javaCode: ` + DateParser parser = new DateParser(); + + // parseDateTime should use local time + Date date1 = parser.parseDateTime("2025-01-15T14:30:45"); + Calendar cal1 = Calendar.getInstance(); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "parseDateTime: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "parseDateTime: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "parseDateTime: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "parseDateTime: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "parseDateTime: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "parseDateTime: second 45"); + ` + }, + + { + name: 'DateParserTest_parseDateTimeUTC', + javaCode: ` + DateParser parser = new DateParser(); + + // parseDateTimeUTC should use UTC + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "parseDateTimeUTC: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "parseDateTimeUTC: month 0"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "parseDateTimeUTC: day 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "parseDateTimeUTC: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "parseDateTimeUTC: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "parseDateTimeUTC: second 45"); + ` + }, + + // ========== Timezone Tests ========== + + { + name: 'DateParserTest_Timezone_Z', + javaCode: ` + DateParser parser = new DateParser(); + + // Test with Z timezone (UTC) + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45Z"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "Timezone Z: year 2025"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "Timezone Z: hour 14 (no offset)"); + test(cal1.get(Calendar.MINUTE) == 30, "Timezone Z: minute 30"); + ` + }, + + { + name: 'DateParserTest_Timezone_Positive', + javaCode: ` + DateParser parser = new DateParser(); + + // Test with +05:30 timezone + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45+05:30"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.HOUR_OF_DAY) == 9, "Timezone +05:30: hour 9 (14:30 - 5:30 = 9:00)"); + test(cal1.get(Calendar.MINUTE) == 0, "Timezone +05:30: minute 0"); + ` + }, + + { + name: 'DateParserTest_Timezone_Negative', + javaCode: ` + DateParser parser = new DateParser(); + + // Test with -05:00 timezone + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45-05:00"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.HOUR_OF_DAY) == 19, "Timezone -05:00: hour 19 (14:30 + 5:00 = 19:30)"); + test(cal1.get(Calendar.MINUTE) == 30, "Timezone -05:00: minute 30"); + ` + }, + + { + name: 'DateParserTest_Timezone_Formats', + javaCode: ` + DateParser parser = new DateParser(); + + // Test +HHMM format (no colon) + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45+0530"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.HOUR_OF_DAY) == 9, "Timezone +0530 (no colon): hour 9"); + + // Test +HH format (hours only) + Date date2 = parser.parseDateTimeUTC("2025-01-15T14:30:45+05"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.HOUR_OF_DAY) == 9, "Timezone +05 (hours only): hour 9"); + test(cal2.get(Calendar.MINUTE) == 30, "Timezone +05 (hours only): minute 30"); + ` + }, + + // ========== Edge Cases ========== + + { + name: 'DateParserTest_LeapYear', + javaCode: ` + DateParser parser = new DateParser(); + + // Test leap year 2000-02-29 + Date date1 = parser.parseString("2000-02-29"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2000, "Leap year: year 2000"); + test(cal1.get(Calendar.MONTH) == 1, "Leap year: month 1 (Feb)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 29, "Leap year: day 29"); + + // Test leap year 2024-02-29 + Date date2 = parser.parseString("2024-02-29"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2024, "Leap year: year 2024"); + test(cal2.get(Calendar.MONTH) == 1, "Leap year: month 1 (Feb)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 29, "Leap year: day 29"); + ` + }, + + { + name: 'DateParserTest_InvalidDates', + javaCode: ` + DateParser parser = new DateParser(); + // Default is non-strict mode - invalid formats return MAX_DATE instead of throwing + + // Test invalid format - returns MAX_DATE in lenient mode + Date date1 = parser.parseString("not-a-date"); + test(date1.equals(DateParser.MAX_DATE), "Invalid format returns MAX_DATE in lenient mode"); + + // Test empty string - returns MAX_DATE in lenient mode + Date date2 = parser.parseString(""); + test(date2.equals(DateParser.MAX_DATE), "Empty string returns MAX_DATE in lenient mode"); + + // Test null - returns MAX_DATE in lenient mode + Date date3 = parser.parseString(null); + test(date3.equals(DateParser.MAX_DATE), "Null returns MAX_DATE in lenient mode"); + ` + }, + + { + name: 'DateParserTest_PartialParse', + javaCode: ` + DateParser parser = new DateParser(); + + // Trailing text is allowed - parser should extract date and ignore trailing text + // Test with trailing text + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45Z extra text"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "Trailing text allowed: year is 2025"); + test(cal1.get(Calendar.MONTH) == 0, "Trailing text allowed: month is January (0)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "Trailing text allowed: day is 15"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "Trailing text allowed: hour is 14 UTC"); + test(cal1.get(Calendar.MINUTE) == 30, "Trailing text allowed: minute is 30"); + test(cal1.get(Calendar.SECOND) == 45, "Trailing text allowed: second is 45"); + ` + }, + + // ========== Strict Validation Mode Tests ========== + + { + name: 'DateParserTest_StrictValidation_ThrowsForInvalid', + javaCode: ` + DateParser parser = new DateParser(); + parser.setStrictValidation(true); + + // Test 1: Invalid format should throw + try { + parser.parseString("not-a-date"); + test(false, "StrictMode: invalid format should throw"); + } catch (RuntimeException e) { + test(e.getMessage().contains("Unsupported Date format"), "StrictMode: invalid format throws correct exception"); + } + + // Test 2: Empty string should throw + try { + parser.parseString(""); + test(false, "StrictMode: empty string should throw"); + } catch (RuntimeException e) { + test(e.getMessage().contains("empty or null"), "StrictMode: empty string throws correct exception"); + } + + // Test 3: Null should throw + try { + parser.parseString(null); + test(false, "StrictMode: null should throw"); + } catch (RuntimeException e) { + test(e.getMessage().contains("empty or null"), "StrictMode: null throws correct exception"); + } + + // Test 4: parseDateTime with invalid input should throw + try { + parser.parseDateTime("garbage"); + test(false, "StrictMode parseDateTime: should throw for invalid input"); + } catch (RuntimeException e) { + test(true, "StrictMode parseDateTime: throws for invalid input"); + } + + // Test 5: parseDateTimeUTC with invalid input should throw + try { + parser.parseDateTimeUTC("invalid"); + test(false, "StrictMode parseDateTimeUTC: should throw for invalid input"); + } catch (RuntimeException e) { + test(true, "StrictMode parseDateTimeUTC: throws for invalid input"); + } + ` + }, + + { + name: 'DateParserTest_StrictValidation_ValidDatesWork', + javaCode: ` + DateParser parser = new DateParser(); + parser.setStrictValidation(true); + + // Test that valid dates still work in strict mode + Date date1 = parser.parseString("2025-01-15"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "StrictMode: valid date parses - year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "StrictMode: valid date parses - month Jan"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "StrictMode: valid date parses - day 15"); + + // Test parseDateTime + Date date2 = parser.parseDateTime("2025-01-15T14:30:45"); + Calendar cal2 = Calendar.getInstance(); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "StrictMode: valid datetime parses - year 2025"); + test(cal2.get(Calendar.HOUR_OF_DAY) == 14, "StrictMode: valid datetime parses - hour 14"); + + // Test parseDateTimeUTC + Date date3 = parser.parseDateTimeUTC("2025-01-15T14:30:45Z"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2025, "StrictMode: valid UTC datetime parses - year 2025"); + test(cal3.get(Calendar.HOUR_OF_DAY) == 14, "StrictMode: valid UTC datetime parses - hour 14"); + ` + }, + + { + name: 'DateParserTest_LenientValidation_ReturnsMaxDate', + javaCode: ` + DateParser parser = new DateParser(); + parser.setStrictValidation(false); + + // Test 1: Default should be lenient (strictValidation = false) + test(parser.getStrictValidation() == false, "Default parser has strictValidation=false"); + + // Test 2: Invalid format should return MAX_DATE, not throw + Date result1 = parser.parseString("not-a-date"); + test(result1.equals(DateParser.MAX_DATE), "LenientMode: invalid format returns MAX_DATE"); + + // Test 3: Empty string should return MAX_DATE + Date result2 = parser.parseString(""); + test(result2.equals(DateParser.MAX_DATE), "LenientMode: empty string returns MAX_DATE"); + + // Test 4: Null should return MAX_DATE + Date result3 = parser.parseString(null); + test(result3.equals(DateParser.MAX_DATE), "LenientMode: null returns MAX_DATE"); + + // Test 5: parseDateTime with invalid returns MAX_DATE + Date result4 = parser.parseDateTime("garbage"); + test(result4.equals(DateParser.MAX_DATE), "LenientMode parseDateTime: invalid returns MAX_DATE"); + + // Test 6: parseDateTimeUTC with invalid returns MAX_DATE + Date result5 = parser.parseDateTimeUTC("invalid"); + test(result5.equals(DateParser.MAX_DATE), "LenientMode parseDateTimeUTC: invalid returns MAX_DATE"); + ` + }, + + { + name: 'DateParserTest_LenientValidation_ValidDatesWork', + javaCode: ` + DateParser parser = new DateParser(); + parser.setStrictValidation(false); + + // Test that valid dates work in lenient mode + Date date1 = parser.parseString("2025-01-15"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "LenientMode: valid date parses - year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "LenientMode: valid date parses - month Jan"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "LenientMode: valid date parses - day 15"); + ` + }, + + // ========== New Format Tests (parity with JavaScript) ========== + + { + name: 'DateParserTest_Timestamps', + javaCode: ` + DateParser parser = new DateParser(); + + // Test 13-digit JavaScript timestamp (milliseconds since epoch) + // 1737028800000 = 2025-01-16T12:00:00.000Z + Date date1 = parser.parseString("1737028800000"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "Timestamp13: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "Timestamp13: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 16, "Timestamp13: day 16"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 12, "Timestamp13: hour 12"); + + // Test 10-digit Unix timestamp (seconds since epoch) + // 1737028800 = 2025-01-16T12:00:00Z + Date date2 = parser.parseString("1737028800"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "Timestamp10: year 2025"); + test(cal2.get(Calendar.MONTH) == 0, "Timestamp10: month 0 (Jan)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 16, "Timestamp10: day 16"); + test(cal2.get(Calendar.HOUR_OF_DAY) == 12, "Timestamp10: hour 12"); + + // Test another timestamp: 0 = Unix epoch (1970-01-01 00:00:00 UTC) + Date date3 = parser.parseString("0000000000"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 1970, "Timestamp10 epoch: year 1970"); + test(cal3.get(Calendar.MONTH) == 0, "Timestamp10 epoch: month 0 (Jan)"); + test(cal3.get(Calendar.DAY_OF_MONTH) == 1, "Timestamp10 epoch: day 1"); + ` + }, + + { + name: 'DateParserTest_SingleDigitMonthDay', + javaCode: ` + DateParser parser = new DateParser(); + + // Test YYYY-M-D (single digit month and day) + Date date1 = parser.parseString("2025-1-5"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "YYYY-M-D: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "YYYY-M-D: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 5, "YYYY-M-D: day 5"); + + // Test YYYY/MM/D (single digit day) + Date date2 = parser.parseString("2025/03/5"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "YYYY/MM/D: year 2025"); + test(cal2.get(Calendar.MONTH) == 2, "YYYY/MM/D: month 2 (Mar)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 5, "YYYY/MM/D: day 5"); + + // Test YYYY-M-DD (single digit month) + Date date3 = parser.parseString("2025-3-15"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2025, "YYYY-M-DD: year 2025"); + test(cal3.get(Calendar.MONTH) == 2, "YYYY-M-DD: month 2 (Mar)"); + test(cal3.get(Calendar.DAY_OF_MONTH) == 15, "YYYY-M-DD: day 15"); + + // Test M/D/YYYY (US format with single digits) + Date date4 = parser.parseString("1/5/2025"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.YEAR) == 2025, "M/D/YYYY: year 2025"); + test(cal4.get(Calendar.MONTH) == 0, "M/D/YYYY: month 0 (Jan)"); + test(cal4.get(Calendar.DAY_OF_MONTH) == 5, "M/D/YYYY: day 5"); + + // Test with time: YYYY-M-D HH:MM:SS + Date date5 = parser.parseDateTime("2025-1-5 14:30:45"); + Calendar cal5 = Calendar.getInstance(); + cal5.setTime(date5); + test(cal5.get(Calendar.YEAR) == 2025, "YYYY-M-D with time: year 2025"); + test(cal5.get(Calendar.MONTH) == 0, "YYYY-M-D with time: month 0 (Jan)"); + test(cal5.get(Calendar.DAY_OF_MONTH) == 5, "YYYY-M-D with time: day 5"); + test(cal5.get(Calendar.HOUR_OF_DAY) == 14, "YYYY-M-D with time: hour 14"); + ` + }, + + { + name: 'DateParserTest_SpaceSeparatedMonthNames', + javaCode: ` + DateParser parser = new DateParser(); + + // Test "Jan 02 2025" (MMM dd yyyy) + Date date1 = parser.parseString("Jan 02 2025"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "MMM dd yyyy: year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "MMM dd yyyy: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 2, "MMM dd yyyy: day 2"); + + // Test "Jan 2 2025" (single digit day) + Date date2 = parser.parseString("Jan 2 2025"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "MMM d yyyy: year 2025"); + test(cal2.get(Calendar.MONTH) == 0, "MMM d yyyy: month 0 (Jan)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 2, "MMM d yyyy: day 2"); + + // Test "15 JAN 2025" (DD MMM YYYY) + Date date3 = parser.parseString("15 JAN 2025"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2025, "DD MMM YYYY: year 2025"); + test(cal3.get(Calendar.MONTH) == 0, "DD MMM YYYY: month 0 (Jan)"); + test(cal3.get(Calendar.DAY_OF_MONTH) == 15, "DD MMM YYYY: day 15"); + + // Test "5 JAN 2025" (single digit day) + Date date4 = parser.parseString("5 JAN 2025"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.YEAR) == 2025, "D MMM YYYY: year 2025"); + test(cal4.get(Calendar.MONTH) == 0, "D MMM YYYY: month 0 (Jan)"); + test(cal4.get(Calendar.DAY_OF_MONTH) == 5, "D MMM YYYY: day 5"); + + // Test case insensitivity: "dec 25 2025" + Date date5 = parser.parseString("dec 25 2025"); + Calendar cal5 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal5.setTime(date5); + test(cal5.get(Calendar.YEAR) == 2025, "mmm dd yyyy (lowercase): year 2025"); + test(cal5.get(Calendar.MONTH) == 11, "mmm dd yyyy (lowercase): month 11 (Dec)"); + test(cal5.get(Calendar.DAY_OF_MONTH) == 25, "mmm dd yyyy (lowercase): day 25"); + ` + }, + + { + name: 'DateParserTest_MMDDYY_Format', + javaCode: ` + DateParser parser = new DateParser(); + + // Test MM/DD/YY with 2-digit year (21st century) + Date date1 = parser.parseString("01/15/25"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "MM/DD/YY: year 2025 (25 -> 2025)"); + test(cal1.get(Calendar.MONTH) == 0, "MM/DD/YY: month 0 (Jan)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "MM/DD/YY: day 15"); + + // Test MM-DD-YY with 2-digit year (20th century - pivot at 50) + Date date2 = parser.parseString("06-15-99"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 1999, "MM-DD-YY: year 1999 (99 -> 1999)"); + test(cal2.get(Calendar.MONTH) == 5, "MM-DD-YY: month 5 (Jun)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 15, "MM-DD-YY: day 15"); + + // Test pivot boundary: 49 -> 2049 + Date date3 = parser.parseString("12/31/49"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2049, "MM/DD/YY: year 2049 (49 -> 2049)"); + + // Test pivot boundary: 50 -> 1950 + Date date4 = parser.parseString("01/01/50"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.YEAR) == 1950, "MM/DD/YY: year 1950 (50 -> 1950)"); + + // Test MMDDYY compact: 011525 + Date date5 = parser.parseString("011525"); + Calendar cal5 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal5.setTime(date5); + test(cal5.get(Calendar.YEAR) == 2025, "MMDDYY compact: year 2025"); + test(cal5.get(Calendar.MONTH) == 0, "MMDDYY compact: month 0 (Jan)"); + test(cal5.get(Calendar.DAY_OF_MONTH) == 15, "MMDDYY compact: day 15"); + + // Test single-digit month/day: M/D/YY + Date date6 = parser.parseString("1/5/25"); + Calendar cal6 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal6.setTime(date6); + test(cal6.get(Calendar.YEAR) == 2025, "M/D/YY: year 2025"); + test(cal6.get(Calendar.MONTH) == 0, "M/D/YY: month 0 (Jan)"); + test(cal6.get(Calendar.DAY_OF_MONTH) == 5, "M/D/YY: day 5"); + ` + }, + + { + name: 'DateParserTest_FractionalSeconds', + javaCode: ` + DateParser parser = new DateParser(); + + // Test with milliseconds (3 digits) + Date date1 = parser.parseDateTimeUTC("2025-01-15T14:30:45.123Z"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "Fractional 3 digits: year 2025"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 14, "Fractional 3 digits: hour 14"); + test(cal1.get(Calendar.MINUTE) == 30, "Fractional 3 digits: minute 30"); + test(cal1.get(Calendar.SECOND) == 45, "Fractional 3 digits: second 45"); + test(cal1.get(Calendar.MILLISECOND) == 123, "Fractional 3 digits: ms 123"); + + // Test with microseconds (6 digits) - truncated to milliseconds + Date date2 = parser.parseDateTimeUTC("2025-01-15T14:30:45.123456Z"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "Fractional 6 digits: year 2025"); + test(cal2.get(Calendar.MILLISECOND) == 123, "Fractional 6 digits: ms 123 (truncated)"); + + // Test with 1 digit - padded to 3 digits (100) + Date date3 = parser.parseDateTimeUTC("2025-01-15T14:30:45.1Z"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.MILLISECOND) == 100, "Fractional 1 digit: ms 100 (padded)"); + + // Test with 2 digits - padded to 3 digits (120) + Date date4 = parser.parseDateTimeUTC("2025-01-15T14:30:45.12Z"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.MILLISECOND) == 120, "Fractional 2 digits: ms 120 (padded)"); + + // Test with timezone offset + Date date5 = parser.parseDateTimeUTC("2025-01-15T14:30:45.500+05:30"); + Calendar cal5 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal5.setTime(date5); + test(cal5.get(Calendar.HOUR_OF_DAY) == 9, "Fractional with tz: hour 9 UTC (14:30 - 5:30)"); + test(cal5.get(Calendar.MILLISECOND) == 500, "Fractional with tz: ms 500"); + ` + }, + + // ========== Unix/Java Date.toString() Format Tests ========== + + { + name: 'DateParserTest_UnixDateToString', + javaCode: ` + DateParser parser = new DateParser(); + + // Test "Tue Apr 01 05:17:59 GMT 2025" + Date date1 = parser.parseDateTimeUTC("Tue Apr 01 05:17:59 GMT 2025"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "Unix Date.toString(): year 2025"); + test(cal1.get(Calendar.MONTH) == 3, "Unix Date.toString(): month 3 (Apr)"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 1, "Unix Date.toString(): day 1"); + test(cal1.get(Calendar.HOUR_OF_DAY) == 5, "Unix Date.toString(): hour 5"); + test(cal1.get(Calendar.MINUTE) == 17, "Unix Date.toString(): minute 17"); + test(cal1.get(Calendar.SECOND) == 59, "Unix Date.toString(): second 59"); + + // Test "Mon Jan 15 14:30:45 GMT 2025" + Date date2 = parser.parseDateTimeUTC("Mon Jan 15 14:30:45 GMT 2025"); + Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "Unix Date.toString() Mon: year 2025"); + test(cal2.get(Calendar.MONTH) == 0, "Unix Date.toString() Mon: month 0 (Jan)"); + test(cal2.get(Calendar.DAY_OF_MONTH) == 15, "Unix Date.toString() Mon: day 15"); + test(cal2.get(Calendar.HOUR_OF_DAY) == 14, "Unix Date.toString() Mon: hour 14"); + test(cal2.get(Calendar.MINUTE) == 30, "Unix Date.toString() Mon: minute 30"); + test(cal2.get(Calendar.SECOND) == 45, "Unix Date.toString() Mon: second 45"); + + // Test "Wed Dec 31 23:59:59 GMT 2024" + Date date3 = parser.parseDateTimeUTC("Wed Dec 31 23:59:59 GMT 2024"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2024, "Unix Date.toString() Wed: year 2024"); + test(cal3.get(Calendar.MONTH) == 11, "Unix Date.toString() Wed: month 11 (Dec)"); + test(cal3.get(Calendar.DAY_OF_MONTH) == 31, "Unix Date.toString() Wed: day 31"); + test(cal3.get(Calendar.HOUR_OF_DAY) == 23, "Unix Date.toString() Wed: hour 23"); + test(cal3.get(Calendar.MINUTE) == 59, "Unix Date.toString() Wed: minute 59"); + test(cal3.get(Calendar.SECOND) == 59, "Unix Date.toString() Wed: second 59"); + + // Test "Thu Feb 29 00:00:00 GMT 2024" (leap year) + Date date4 = parser.parseDateTimeUTC("Thu Feb 29 00:00:00 GMT 2024"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.YEAR) == 2024, "Unix Date.toString() leap year: year 2024"); + test(cal4.get(Calendar.MONTH) == 1, "Unix Date.toString() leap year: month 1 (Feb)"); + test(cal4.get(Calendar.DAY_OF_MONTH) == 29, "Unix Date.toString() leap year: day 29"); + + // Test with UTC timezone + Date date5 = parser.parseDateTimeUTC("Fri Jun 15 12:00:00 UTC 2025"); + Calendar cal5 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal5.setTime(date5); + test(cal5.get(Calendar.YEAR) == 2025, "Unix Date.toString() UTC: year 2025"); + test(cal5.get(Calendar.MONTH) == 5, "Unix Date.toString() UTC: month 5 (Jun)"); + test(cal5.get(Calendar.DAY_OF_MONTH) == 15, "Unix Date.toString() UTC: day 15"); + test(cal5.get(Calendar.HOUR_OF_DAY) == 12, "Unix Date.toString() UTC: hour 12"); + + // Test case insensitivity - lowercase + Date date6 = parser.parseDateTimeUTC("tue apr 01 05:17:59 gmt 2025"); + Calendar cal6 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal6.setTime(date6); + test(cal6.get(Calendar.YEAR) == 2025, "Unix Date.toString() lowercase: year 2025"); + test(cal6.get(Calendar.MONTH) == 3, "Unix Date.toString() lowercase: month 3 (Apr)"); + test(cal6.get(Calendar.DAY_OF_MONTH) == 1, "Unix Date.toString() lowercase: day 1"); + + // Test case insensitivity - mixed case + Date date7 = parser.parseDateTimeUTC("Sat Jul 04 09:30:00 Gmt 2025"); + Calendar cal7 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal7.setTime(date7); + test(cal7.get(Calendar.YEAR) == 2025, "Unix Date.toString() mixed case: year 2025"); + test(cal7.get(Calendar.MONTH) == 6, "Unix Date.toString() mixed case: month 6 (Jul)"); + test(cal7.get(Calendar.DAY_OF_MONTH) == 4, "Unix Date.toString() mixed case: day 4"); + + // Test with single digit day (should work since dayFlexible is used) + Date date8 = parser.parseDateTimeUTC("Sun Mar 5 08:15:30 GMT 2025"); + Calendar cal8 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal8.setTime(date8); + test(cal8.get(Calendar.YEAR) == 2025, "Unix Date.toString() single digit day: year 2025"); + test(cal8.get(Calendar.MONTH) == 2, "Unix Date.toString() single digit day: month 2 (Mar)"); + test(cal8.get(Calendar.DAY_OF_MONTH) == 5, "Unix Date.toString() single digit day: day 5"); + + // Test all days of week are recognized + Date dateMon = parser.parseDateTimeUTC("Mon Jan 01 00:00:00 GMT 2024"); + test(dateMon != null && !dateMon.equals(DateParser.MAX_DATE), "Unix Date.toString(): Mon recognized"); + + Date dateTue = parser.parseDateTimeUTC("Tue Jan 02 00:00:00 GMT 2024"); + test(dateTue != null && !dateTue.equals(DateParser.MAX_DATE), "Unix Date.toString(): Tue recognized"); + + Date dateWed = parser.parseDateTimeUTC("Wed Jan 03 00:00:00 GMT 2024"); + test(dateWed != null && !dateWed.equals(DateParser.MAX_DATE), "Unix Date.toString(): Wed recognized"); + + Date dateThu = parser.parseDateTimeUTC("Thu Jan 04 00:00:00 GMT 2024"); + test(dateThu != null && !dateThu.equals(DateParser.MAX_DATE), "Unix Date.toString(): Thu recognized"); + + Date dateFri = parser.parseDateTimeUTC("Fri Jan 05 00:00:00 GMT 2024"); + test(dateFri != null && !dateFri.equals(DateParser.MAX_DATE), "Unix Date.toString(): Fri recognized"); + + Date dateSat = parser.parseDateTimeUTC("Sat Jan 06 00:00:00 GMT 2024"); + test(dateSat != null && !dateSat.equals(DateParser.MAX_DATE), "Unix Date.toString(): Sat recognized"); + + Date dateSun = parser.parseDateTimeUTC("Sun Jan 07 00:00:00 GMT 2024"); + test(dateSun != null && !dateSun.equals(DateParser.MAX_DATE), "Unix Date.toString(): Sun recognized"); + + // Test all months are recognized + String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + for ( int i = 0 ; i < months.length ; i++ ) { + String dayPadded = String.format("%02d", (i % 28) + 1); + String dateStr = "Mon " + months[i] + " " + dayPadded + " 12:00:00 GMT 2025"; + Date dateMonth = parser.parseDateTimeUTC(dateStr); + Calendar calMonth = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calMonth.setTime(dateMonth); + test(calMonth.get(Calendar.MONTH) == i, "Unix Date.toString(): " + months[i] + " recognized as month " + i); + } + + // Test with numeric timezone offset +HHMM + // "Tue Apr 01 10:17:59 +0500 2025" - 10:17:59 +05:00 = 05:17:59 UTC + Date dateOffset1 = parser.parseDateTimeUTC("Tue Apr 01 10:17:59 +0500 2025"); + Calendar calOffset1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calOffset1.setTime(dateOffset1); + test(calOffset1.get(Calendar.YEAR) == 2025, "Unix Date.toString() +0500: year 2025"); + test(calOffset1.get(Calendar.MONTH) == 3, "Unix Date.toString() +0500: month 3 (Apr)"); + test(calOffset1.get(Calendar.DAY_OF_MONTH) == 1, "Unix Date.toString() +0500: day 1"); + test(calOffset1.get(Calendar.HOUR_OF_DAY) == 5, "Unix Date.toString() +0500: hour 5 UTC (10 - 5)"); + test(calOffset1.get(Calendar.MINUTE) == 17, "Unix Date.toString() +0500: minute 17"); + + // Test with negative timezone offset + // "Tue Apr 01 00:17:59 -0500 2025" - 00:17:59 -05:00 = 05:17:59 UTC + Date dateOffset2 = parser.parseDateTimeUTC("Tue Apr 01 00:17:59 -0500 2025"); + Calendar calOffset2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calOffset2.setTime(dateOffset2); + test(calOffset2.get(Calendar.YEAR) == 2025, "Unix Date.toString() -0500: year 2025"); + test(calOffset2.get(Calendar.HOUR_OF_DAY) == 5, "Unix Date.toString() -0500: hour 5 UTC (0 + 5)"); + test(calOffset2.get(Calendar.MINUTE) == 17, "Unix Date.toString() -0500: minute 17"); + + // Test with +0000 (same as GMT) + Date dateOffset3 = parser.parseDateTimeUTC("Tue Apr 01 05:17:59 +0000 2025"); + Calendar calOffset3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calOffset3.setTime(dateOffset3); + test(calOffset3.get(Calendar.HOUR_OF_DAY) == 5, "Unix Date.toString() +0000: hour 5 UTC (same as GMT)"); + + // Test with +HH:MM format (colon separator) + Date dateOffset4 = parser.parseDateTimeUTC("Tue Apr 01 10:17:59 +05:00 2025"); + Calendar calOffset4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + calOffset4.setTime(dateOffset4); + test(calOffset4.get(Calendar.HOUR_OF_DAY) == 5, "Unix Date.toString() +05:00: hour 5 UTC (10 - 5)"); + ` + } + ] +}); diff --git a/src/foam/parse/test/DateParserTest.js b/src/foam/parse/test/DateParserTest.js index e84023e039..33d3d83c82 100644 --- a/src/foam/parse/test/DateParserTest.js +++ b/src/foam/parse/test/DateParserTest.js @@ -22,6 +22,7 @@ foam.CLASS({ this.testDDMMYYYYFormats(x); this.testYYYYDDMMFormats(x); this.testDDMMMYYYYFormats(x); + this.testUnixDateToStringFormat(x); this.testDateTimeFormats(x); this.testFractionalSeconds(x); this.testParseDateString(x); @@ -39,6 +40,9 @@ foam.CLASS({ this.testInvalidLeapYearDates(x); this.testAllParseMethodsExample(x); this.testTimestampStrings(x); + this.testStrictValidationMode(x); + this.testLenientValidationMode(x); + this.testInvalidMonthNameValidation(x); }, function testYYYYMMDDFormats(x) { @@ -1234,7 +1238,7 @@ foam.CLASS({ function testValidation(x) { let parser = this.DateParser.create(); - // Test that parser returns MAX_DATE for truly unparseable inputs + // In non-strict mode (default), unparseable inputs return MAX_DATE let invalidInputs = [ 'invalid-date', '99/99/99', @@ -1243,13 +1247,8 @@ foam.CLASS({ ]; invalidInputs.forEach((input, i) => { - try { - let result = parser.parseString(input); - let isMaxDate = result && result.getTime() === foam.Date.MAX_DATE.getTime(); - x.test(isMaxDate, `Validation Test${i + 1}: "${input}" should return MAX_DATE (invalid)`); - } catch (e) { - x.test(false, `Validation Test${i + 1}: "${input}" - ${e.message}`); - } + let result = parser.parseString(input); + x.test(result.getTime() === foam.Date.MAX_DATE.getTime(), `Validation Test${i + 1}: "${input}" returns MAX_DATE in lenient mode`); }); // Test date normalization - JavaScript Date normalizes out-of-range values @@ -2064,6 +2063,278 @@ foam.CLASS({ x.test(false, `DateTime-Timestamp Test${i + 1}: ${testCase.desc} - Error: ${e.message}`); } }); + }, + + function testStrictValidationMode(x) { + // Test strict validation mode - should throw errors for invalid dates + let parser = this.DateParser.create(); + + // Enable strict validation for this test + parser.strictValidation = true; + + try { + // Test 1: Invalid format should throw + let invalidInputs = [ + { input: 'invalid-date', desc: 'completely invalid string' }, + { input: 'notadate', desc: 'non-date text' }, + { input: '', desc: 'empty string' } + ]; + + invalidInputs.forEach((testCase, i) => { + try { + parser.parseString(testCase.input); + x.test(false, `StrictMode parseString Test${i + 1}: "${testCase.input}" should throw (${testCase.desc})`); + } catch (e) { + x.test(true, `StrictMode parseString Test${i + 1}: "${testCase.input}" throws error as expected (${testCase.desc})`); + } + }); + + // Test 2: parseDateTime with invalid input should throw + try { + parser.parseDateTime('not-a-date'); + x.test(false, 'StrictMode parseDateTime: should throw for invalid input'); + } catch (e) { + x.test(true, 'StrictMode parseDateTime: throws error for invalid input'); + } + + // Test 3: parseDateTimeUTC with invalid input should throw + try { + parser.parseDateTimeUTC('garbage'); + x.test(false, 'StrictMode parseDateTimeUTC: should throw for invalid input'); + } catch (e) { + x.test(true, 'StrictMode parseDateTimeUTC: throws error for invalid input'); + } + + // Test 4: Valid dates should still work in strict mode + try { + let result = parser.parseString('2025-01-15'); + x.test(result.getUTCFullYear() === 2025, 'StrictMode: valid date parses correctly'); + } catch (e) { + x.test(false, 'StrictMode: valid date should not throw - ' + e.message); + } + + // Test 5: Valid datetime should work in strict mode + try { + let result = parser.parseDateTime('2025-01-15T14:30:45'); + x.test(result.getFullYear() === 2025, 'StrictMode: valid datetime parses correctly'); + } catch (e) { + x.test(false, 'StrictMode: valid datetime should not throw - ' + e.message); + } + } finally { + // Reset to default + parser.strictValidation = false; + } + }, + + function testLenientValidationMode(x) { + // Test lenient validation mode (default) - should return MAX_DATE for invalid dates + let parser = this.DateParser.create(); + + // Ensure lenient mode (default) + parser.strictValidation = false; + + // Test 1: Invalid format returns MAX_DATE in lenient mode + let invalidInputs = [ + { input: 'invalid-date', desc: 'completely invalid string' }, + { input: 'notadate', desc: 'non-date text' } + ]; + + invalidInputs.forEach((testCase, i) => { + let result = parser.parseString(testCase.input); + x.test(result.getTime() === foam.Date.MAX_DATE.getTime(), `LenientMode parseString Test${i + 1}: "${testCase.input}" returns MAX_DATE (${testCase.desc})`); + }); + + // Test 2: Empty string returns MAX_DATE in lenient mode + let emptyResult = parser.parseString(''); + x.test(emptyResult.getTime() === foam.Date.MAX_DATE.getTime(), 'LenientMode: empty string returns MAX_DATE'); + + // Test 3: Valid dates should work in lenient mode + try { + let result = parser.parseString('2025-01-15'); + x.test(result.getUTCFullYear() === 2025, 'LenientMode: valid date parses correctly'); + } catch (e) { + x.test(false, 'LenientMode: valid date should not throw - ' + e.message); + } + + // Test 4: Default parser should be lenient + x.test(parser.strictValidation === false, 'Default parser has strictValidation=false'); + }, + + /** + * Test Unix/Java Date.toString() format: DDD MMM DD HH:MM:SS TZ YYYY + * e.g., "Tue Apr 01 05:17:59 GMT 2025" + */ + function testUnixDateToStringFormat(x) { + let parser = this.DateParser.create(); + + // Basic test cases - different days of week + let basicCases = [ + { input: 'Tue Apr 01 05:17:59 GMT 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + { input: 'Mon Jan 15 12:30:45 GMT 2025', year: 2025, month: 0, day: 15, hour: 12, minute: 30, second: 45 }, + { input: 'Wed Feb 28 23:59:59 GMT 2024', year: 2024, month: 1, day: 28, hour: 23, minute: 59, second: 59 }, + { input: 'Thu Mar 01 00:00:00 GMT 2024', year: 2024, month: 2, day: 1, hour: 0, minute: 0, second: 0 }, + { input: 'Fri Dec 31 18:45:30 GMT 2025', year: 2025, month: 11, day: 31, hour: 18, minute: 45, second: 30 }, + { input: 'Sat Jul 04 09:15:00 GMT 2025', year: 2025, month: 6, day: 4, hour: 9, minute: 15, second: 0 }, + { input: 'Sun Nov 11 11:11:11 GMT 2025', year: 2025, month: 10, day: 11, hour: 11, minute: 11, second: 11 } + ]; + + // Test with UTC results (since GMT timezone should normalize to UTC) + basicCases.forEach((testCase, i) => { + let result = this.testParseDTUTCWithDetails(parser, testCase.input, testCase.year, testCase.month, testCase.day, testCase.hour, testCase.minute, testCase.second); + let testName = `UnixDate-Basic Test${i + 1}: ${testCase.input}`; + if ( ! result.pass && result.message ) { + testName += ` - ${result.message}`; + } + x.test(result.pass, testName); + }); + + // Test case-insensitive parsing + let caseInsensitiveCases = [ + { input: 'TUE APR 01 05:17:59 GMT 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + { input: 'tue apr 01 05:17:59 gmt 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + { input: 'Tue Apr 01 05:17:59 gmt 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + { input: 'tue APR 01 05:17:59 GMT 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 } + ]; + + caseInsensitiveCases.forEach((testCase, i) => { + let result = this.testParseDTUTCWithDetails(parser, testCase.input, testCase.year, testCase.month, testCase.day, testCase.hour, testCase.minute, testCase.second); + let testName = `UnixDate-CaseInsensitive Test${i + 1}: ${testCase.input}`; + if ( ! result.pass && result.message ) { + testName += ` - ${result.message}`; + } + x.test(result.pass, testName); + }); + + // Test with UTC timezone + let utcCases = [ + { input: 'Tue Apr 01 05:17:59 UTC 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + { input: 'Mon Jan 15 12:30:45 utc 2025', year: 2025, month: 0, day: 15, hour: 12, minute: 30, second: 45 } + ]; + + utcCases.forEach((testCase, i) => { + let result = this.testParseDTUTCWithDetails(parser, testCase.input, testCase.year, testCase.month, testCase.day, testCase.hour, testCase.minute, testCase.second); + let testName = `UnixDate-UTC Test${i + 1}: ${testCase.input}`; + if ( ! result.pass && result.message ) { + testName += ` - ${result.message}`; + } + x.test(result.pass, testName); + }); + + // Test with timezone offsets - these should convert to UTC correctly + let offsetCases = [ + // +05:00 means 5 hours ahead of UTC, so UTC time is 5 hours earlier + { input: 'Tue Apr 01 10:17:59 +0500 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + // -05:00 means 5 hours behind UTC, so UTC time is 5 hours later + { input: 'Tue Apr 01 00:17:59 -0500 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + // +00:00 is same as GMT/UTC + { input: 'Tue Apr 01 05:17:59 +0000 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 } + ]; + + offsetCases.forEach((testCase, i) => { + let result = this.testParseDTUTCWithDetails(parser, testCase.input, testCase.year, testCase.month, testCase.day, testCase.hour, testCase.minute, testCase.second); + let testName = `UnixDate-Offset Test${i + 1}: ${testCase.input}`; + if ( ! result.pass && result.message ) { + testName += ` - ${result.message}`; + } + x.test(result.pass, testName); + }); + + // Test all months + let monthCases = [ + { input: 'Wed Jan 01 12:00:00 GMT 2025', year: 2025, month: 0, day: 1 }, + { input: 'Sat Feb 01 12:00:00 GMT 2025', year: 2025, month: 1, day: 1 }, + { input: 'Sat Mar 01 12:00:00 GMT 2025', year: 2025, month: 2, day: 1 }, + { input: 'Tue Apr 01 12:00:00 GMT 2025', year: 2025, month: 3, day: 1 }, + { input: 'Thu May 01 12:00:00 GMT 2025', year: 2025, month: 4, day: 1 }, + { input: 'Sun Jun 01 12:00:00 GMT 2025', year: 2025, month: 5, day: 1 }, + { input: 'Tue Jul 01 12:00:00 GMT 2025', year: 2025, month: 6, day: 1 }, + { input: 'Fri Aug 01 12:00:00 GMT 2025', year: 2025, month: 7, day: 1 }, + { input: 'Mon Sep 01 12:00:00 GMT 2025', year: 2025, month: 8, day: 1 }, + { input: 'Wed Oct 01 12:00:00 GMT 2025', year: 2025, month: 9, day: 1 }, + { input: 'Sat Nov 01 12:00:00 GMT 2025', year: 2025, month: 10, day: 1 }, + { input: 'Mon Dec 01 12:00:00 GMT 2025', year: 2025, month: 11, day: 1 } + ]; + + monthCases.forEach((testCase, i) => { + let result = this.testParseDTUTCWithDetails(parser, testCase.input, testCase.year, testCase.month, testCase.day, 12, 0, 0); + let testName = `UnixDate-Month Test${i + 1}: ${testCase.input}`; + if ( ! result.pass && result.message ) { + testName += ` - ${result.message}`; + } + x.test(result.pass, testName); + }); + + // Test single digit day (should work with dayFlexible) + let singleDigitDayCases = [ + { input: 'Tue Apr 1 05:17:59 GMT 2025', year: 2025, month: 3, day: 1, hour: 5, minute: 17, second: 59 }, + { input: 'Wed Jan 5 10:30:00 GMT 2025', year: 2025, month: 0, day: 5, hour: 10, minute: 30, second: 0 } + ]; + + singleDigitDayCases.forEach((testCase, i) => { + let result = this.testParseDTUTCWithDetails(parser, testCase.input, testCase.year, testCase.month, testCase.day, testCase.hour, testCase.minute, testCase.second); + let testName = `UnixDate-SingleDigitDay Test${i + 1}: ${testCase.input}`; + if ( ! result.pass && result.message ) { + testName += ` - ${result.message}`; + } + x.test(result.pass, testName); + }); + + // Test parseString (returns Date at noon UTC for date-only, or with time for datetime) + let parseStringCases = [ + { input: 'Tue Apr 01 05:17:59 GMT 2025', year: 2025, month: 3, day: 1 } + ]; + + parseStringCases.forEach((testCase, i) => { + try { + let result = parser.parseString(testCase.input); + // parseString with time should keep time in local time + let pass = result && + result.getFullYear() === testCase.year && + result.getMonth() === testCase.month && + result.getDate() === testCase.day; + x.test(pass, `UnixDate-parseString Test${i + 1}: ${testCase.input}`); + } catch (e) { + x.test(false, `UnixDate-parseString Test${i + 1}: ${testCase.input} - ${e.message}`); + } + }); + + // Test parseDateString (returns date only at noon UTC) + parseStringCases.forEach((testCase, i) => { + try { + let result = parser.parseDateString(testCase.input); + let pass = result && + result.getUTCFullYear() === testCase.year && + result.getUTCMonth() === testCase.month && + result.getUTCDate() === testCase.day && + result.getUTCHours() === 12; + x.test(pass, `UnixDate-parseDateString Test${i + 1}: ${testCase.input}`); + } catch (e) { + x.test(false, `UnixDate-parseDateString Test${i + 1}: ${testCase.input} - ${e.message}`); + } + }); + }, + + function testInvalidMonthNameValidation(x) { + // Test invalid month name handling in both modes + // Note: "XYZ" doesn't match the grammar's month pattern, so it fails at grammar level + // and returns MAX_DATE (lenient) or throws (strict) + let parser = this.DateParser.create(); + + // Strict mode test + parser.strictValidation = true; + try { + parser.parseString('15-XYZ-2025'); + x.test(false, 'StrictMode: invalid month name "XYZ" should throw'); + } catch (e) { + // Grammar validation happens before parseMonthName, so we may get "Unsupported Date format" + let validError = e.message.includes('Invalid month name') || e.message.includes('Unsupported Date format'); + x.test(validError, 'StrictMode: invalid month name throws error'); + } + + // Lenient mode test - returns MAX_DATE for unparseable input + parser.strictValidation = false; + let result = parser.parseString('15-XYZ-2025'); + x.test(result.getTime() === foam.Date.MAX_DATE.getTime(), 'LenientMode: unparseable returns MAX_DATE'); } ] }); diff --git a/src/foam/parse/test/tests.jrl b/src/foam/parse/test/tests.jrl index de2c2c4d5c..071ec58866 100644 --- a/src/foam/parse/test/tests.jrl +++ b/src/foam/parse/test/tests.jrl @@ -3,4 +3,5 @@ p({"class":"foam.parse.test.QueryParserJSTest","id":"QueryParserJSTest", languag p({"class":"foam.parse.test.FScriptParserTest","id":"FScriptParserTest"}) p({"class":"foam.parse.test.SimpleQueryParserTest","id":"SimpleQueryParserTest", language: 0}) p({"class":"foam.parse.test.DateParserTest","id":"DateParserTest", language: 0}) -p({"class":"foam.parse.test.NumberParserTest","id":"NumberParserTest", language: 0}) \ No newline at end of file +p({"class":"foam.parse.test.NumberParserTest","id":"NumberParserTest", language: 0}) +p({"class":"foam.parse.test.DateParserJavaTest","id":"DateParserJavaTest"}) diff --git a/src/foam/util/DateUtil.js b/src/foam/util/DateUtil.js index f828a13ef9..721a5568a0 100644 --- a/src/foam/util/DateUtil.js +++ b/src/foam/util/DateUtil.js @@ -20,6 +20,7 @@ foam.CLASS({ javaImports: [ 'foam.lang.X', 'foam.dao.DAO', + 'foam.parse.DateParser', 'foam.time.TimeZone', 'foam.util.SafetyUtil', 'java.text.ParseException', @@ -36,16 +37,6 @@ foam.CLASS({ { name: 'INVALID_FORMAT', message: 'Invalid format.' } ], - properties: [ - { - name: 'parser_', - documentation: 'Shared DateParser instance for all date parsing operations', - factory: function() { - return this.DateParser.create(); - } - } - ], - constants: [ { name: 'MAX_DATE', @@ -72,17 +63,13 @@ foam.CLASS({ d = String(d); } - var parser = foam.util.DateUtil.parser_ || foam.parse.DateParser.create(); + var parser = foam.parse.DateParser.create(); return parser.parseDateTime(d, opt_name); }, javaCode: ` - // TODO: When migrating to Java grammar-based parsing, replace this with DateParser.parseDateTime() - // Parses datetime string in local timezone - try { - return parseDateTimeWithTimezone(d, java.util.TimeZone.getDefault().getID()); - } catch ( ParseException e ) { - throw new RuntimeException("Cannot parse invalid datetime: " + d); - } + // Parses datetime string in local timezone using grammar-based DateParser + DateParser parser = new DateParser(); + return parser.parseDateTime(d, opt_name); ` }, { @@ -99,17 +86,13 @@ foam.CLASS({ d = String(d); } - var parser = foam.util.DateUtil.parser_ || foam.parse.DateParser.create(); + var parser = foam.parse.DateParser.create(); return parser.parseDateTimeUTC(d, opt_name); }, javaCode: ` - // TODO: When migrating to Java grammar-based parsing, replace this with DateParser.parseDateTimeUTC() - // Parses datetime string in UTC timezone - try { - return parseDateTimeWithTimezone(d, "GMT"); - } catch ( ParseException e ) { - throw new RuntimeException("Cannot parse invalid datetime: " + d); - } + // Parses datetime string in UTC timezone using grammar-based DateParser + DateParser parser = new DateParser(); + return parser.parseDateTimeUTC(d, opt_name); ` }, { @@ -126,90 +109,45 @@ foam.CLASS({ d = String(d); } - var parser = foam.util.DateUtil.parser_ || foam.parse.DateParser.create(); + var parser = foam.parse.DateParser.create(); return parser.parseDateString(d, opt_name); }, javaCode: ` - /* - TODO: When migrating to Java grammar-based parsing, replace this with DateParser.parseDateString() - - Supported formats (checked in order): - 1. YYYY/MM/DD, YYYY-MM-DD (with separators) - 2. YYYYMMDD (no separators, year 1900-2999) - 3. MM/DD/YYYY, MM-DD-YYYY (with separators) - 4. MMDDYYYY (no separators) - 5. YY/MM/DD, YY-MM-DD (with separators, 2-digit year with sliding window) - 6. YYMMDD (no separators, 2-digit year with sliding window) - */ - SimpleDateFormat format; - Date date; - try { - // YYYY/MM/DD or YYYY-MM-DD (with separators) - if ( d.matches("^\\\\d{4}[-/]\\\\d{2}[-/]\\\\d{2}(?!\\\\d).*") ) { - format = new SimpleDateFormat("yyyyMMdd"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); - date = format.parse(d.replaceAll("[-/]", "").substring(0, 8)); - } - // YYYYMMDD (no separators, year must be 1900-2999) - else if ( d.matches("^(1[9]\\\\d{2}|2\\\\d{3})\\\\d{2}\\\\d{2}(?!\\\\d).*") ) { - format = new SimpleDateFormat("yyyyMMdd"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); - date = format.parse(d.substring(0, 8)); - } - // MM/DD/YYYY or MM-DD-YYYY (with separators) - else if ( d.matches("^\\\\d{2}[-/]\\\\d{2}[-/]\\\\d{4}(?!\\\\d).*") ) { - format = new SimpleDateFormat("MMddyyyy"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); - date = format.parse(d.replaceAll("[-/]", "").substring(0, 8)); - } - // MMDDYYYY (no separators) - else if ( d.matches("^\\\\d{8}(?!\\\\d).*") ) { - format = new SimpleDateFormat("MMddyyyy"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); - date = format.parse(d.substring(0, 8)); - } - // YY/MM/DD or YY-MM-DD (with separators) - else if ( d.matches("^\\\\d{2}[-/]\\\\d{2}[-/]\\\\d{2}(?!\\\\d).*") ) { - format = new SimpleDateFormat("yyMMdd"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); - // Sliding window: 100-year window centered on current year (50 years back, 50 years forward) - java.util.Calendar cal = java.util.Calendar.getInstance(); - cal.add(java.util.Calendar.YEAR, -50); - format.set2DigitYearStart(cal.getTime()); - date = format.parse(d.replaceAll("[-/]", "").substring(0, 6)); - } - // YYMMDD (no separators) - else if ( d.matches("^\\\\d{6}(?!\\\\d).*") ) { - format = new SimpleDateFormat("yyMMdd"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone("GMT")); - // Sliding window: 100-year window centered on current year (50 years back, 50 years forward) - java.util.Calendar cal = java.util.Calendar.getInstance(); - cal.add(java.util.Calendar.YEAR, -50); - format.set2DigitYearStart(cal.getTime()); - date = format.parse(d.substring(0, 6)); - } - else { - throw new RuntimeException("Unsupported Date format: " + d); - } - - // Normalize to noon GMT (12:00:00) - java.util.Calendar cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("GMT")); - cal.setTime(date); - cal.set(java.util.Calendar.HOUR_OF_DAY, 12); - cal.set(java.util.Calendar.MINUTE, 0); - cal.set(java.util.Calendar.SECOND, 0); - cal.set(java.util.Calendar.MILLISECOND, 0); - date = cal.getTime(); - } catch ( ParseException e ) { - throw new RuntimeException("Cannot parse invalid date: " + d); - } - return date; + // Parses date string using grammar-based DateParser + // Supports YYYY/MM/DD, MM/DD/YYYY, YY/MM/DD and compact formats + // Optional opt_name to specify format (e.g., "ddmmyyyy") + DateParser parser = new DateParser(); + return parser.parseDateString(d, opt_name); + ` + }, + { + name: 'setStrictValidation', + args: 'Boolean strict', + type: 'Void', + documentation: 'Sets the strict validation mode for date parsing. When true, invalid dates throw errors. When false (default), invalid dates log warnings and return MAX_DATE.', + code: function(strict) { + // DateParser is a Singleton, so create() returns the same instance + var parser = foam.parse.DateParser.create(); + parser.strictValidation = strict; + }, + javaCode: ` + DateParser parser = new DateParser(); + parser.setStrictValidation(strict); + ` + }, + { + name: 'getStrictValidation', + args: '', + type: 'Boolean', + documentation: 'Gets the current strict validation mode for date parsing.', + code: function() { + // DateParser is a Singleton, so create() returns the same instance + var parser = foam.parse.DateParser.create(); + return parser.strictValidation; + }, + javaCode: ` + DateParser parser = new DateParser(); + return parser.getStrictValidation(); ` }, { @@ -221,7 +159,7 @@ foam.CLASS({ if ( ! o ) return null; if ( o instanceof Date ) return o; if ( foam.String.isInstance(o) ) { - var parser = foam.util.DateUtil.parser_ || foam.parse.DateParser.create(); + var parser = foam.parse.DateParser.create(); return parser.parseDateString(o); } if ( typeof o === 'number' ) return new Date(o); @@ -244,12 +182,7 @@ foam.CLASS({ } if ( o instanceof String ) { - try { - return parseDateString((String) o, null); - } catch ( RuntimeException e ) { - // Return MAX_DATE for invalid strings instead of throwing exception - return MAX_DATE; - } + return parseDateString((String) o, null); } if ( o instanceof Number ) return new Date(((Number) o).longValue()); @@ -484,118 +417,6 @@ foam.CLASS({ ], javaCode: ` - /* - * Generic Java datetime parsing helper used by both parseDateTime and parseDateTimeUTC - */ - private static Date parseDateTimeWithTimezone(String d, String timeZoneId) throws ParseException { - SimpleDateFormat format; - Date date; - - // Check for ISO 8601 timezone suffix (Z, +HH:MM, -HH:MM, +HHMM, -HHMM) - // If present, use it instead of the provided timeZoneId - String actualTimeZone = timeZoneId; - String dateTimePart = d; - - // Check for Z (UTC) suffix - if ( d.matches(".*[0-9]Z$") ) { - actualTimeZone = "GMT"; - dateTimePart = d.substring(0, d.length() - 1); - } - // Check for +HH:MM or -HH:MM format - else if ( d.matches(".*[+-]\\\\d{2}:\\\\d{2}$") ) { - String offset = d.substring(d.length() - 6); - actualTimeZone = "GMT" + offset; - dateTimePart = d.substring(0, d.length() - 6); - } - // Check for +HHMM or -HHMM format (no colon) - else if ( d.matches(".*[+-]\\\\d{4}$") ) { - String offset = d.substring(d.length() - 5); - // Convert +HHMM to +HH:MM - actualTimeZone = "GMT" + offset.substring(0, 3) + ":" + offset.substring(3); - dateTimePart = d.substring(0, d.length() - 5); - } - - // ISO 8601: YYYY-MM-DDTHH:MM:SS.SSS or YYYY-MM-DD HH:MM:SS.SSS - if ( dateTimePart.matches("^\\\\d{4}[-/]\\\\d{2}[-/]\\\\d{2}[T ]\\\\d{2}:\\\\d{2}:\\\\d{2}(?:\\\\.\\\\d{3})?$") ) { - String normalized = dateTimePart.replaceAll("[T ]", " ").replaceAll("[-/]", "-"); - int dotIndex = normalized.indexOf('.'); - - if ( dotIndex > 0 ) { - format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - date = format.parse(normalized.substring(0, Math.min(23, normalized.length()))); - } else { - format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - date = format.parse(normalized.substring(0, Math.min(19, normalized.length()))); - } - } - // ISO 8601 short: YYYY-MM-DDTHH:MM or YYYY-MM-DD HH:MM - else if ( dateTimePart.matches("^\\\\d{4}[-/]\\\\d{2}[-/]\\\\d{2}[T ]\\\\d{2}:\\\\d{2}$") ) { - String normalized = dateTimePart.replaceAll("[T ]", " ").replaceAll("[-/]", "-"); - format = new SimpleDateFormat("yyyy-MM-dd HH:mm"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - date = format.parse(normalized.substring(0, Math.min(16, normalized.length()))); - } - // US format with time: MM/DD/YYYY HH:MM:SS or MM-DD-YYYY HH:MM:SS - else if ( dateTimePart.matches("^\\\\d{2}[-/]\\\\d{2}[-/]\\\\d{4} \\\\d{2}:\\\\d{2}:\\\\d{2}$") ) { - String normalized = dateTimePart.replaceAll("/", "-"); - format = new SimpleDateFormat("MM-dd-yyyy HH:mm:ss"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - date = format.parse(normalized.substring(0, Math.min(19, normalized.length()))); - } - // US format with time short: MM/DD/YYYY HH:MM or MM-DD-YYYY HH:MM - else if ( dateTimePart.matches("^\\\\d{2}[-/]\\\\d{2}[-/]\\\\d{4} \\\\d{2}:\\\\d{2}$") ) { - String normalized = dateTimePart.replaceAll("/", "-"); - format = new SimpleDateFormat("MM-dd-yyyy HH:mm"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - date = format.parse(normalized.substring(0, Math.min(16, normalized.length()))); - } - // Compact: YYYYMMDDHHMMSS - else if ( dateTimePart.matches("^(1[9]\\\\d{2}|2\\\\d{3})\\\\d{2}\\\\d{2}\\\\d{2}\\\\d{2}\\\\d{2}$") ) { - format = new SimpleDateFormat("yyyyMMddHHmmss"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - date = format.parse(dateTimePart.substring(0, 14)); - } - // 2-digit year format with time and seconds: YY-MM-DD HH:MM:SS or YY/MM/DD HH:MM:SS - else if ( dateTimePart.matches("^\\\\d{2}[-/]\\\\d{2}[-/]\\\\d{2} \\\\d{2}:\\\\d{2}:\\\\d{2}$") ) { - String normalized = dateTimePart.replaceAll("/", "-"); - // Parse with 4-digit year format to get full control over year conversion - format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - // Extract 2-digit year and convert: 00-49 → 2000-2049, 50-99 → 1950-1999 - int yy = Integer.parseInt(normalized.substring(0, 2)); - int yyyy = yy < 50 ? 2000 + yy : 1900 + yy; - String fullDate = yyyy + normalized.substring(2); - date = format.parse(fullDate); - } - // 2-digit year format with time (no seconds): YY-MM-DD HH:MM or YY/MM/DD HH:MM - else if ( dateTimePart.matches("^\\\\d{2}[-/]\\\\d{2}[-/]\\\\d{2} \\\\d{2}:\\\\d{2}$") ) { - String normalized = dateTimePart.replaceAll("/", "-"); - // Parse with 4-digit year format to get full control over year conversion - format = new SimpleDateFormat("yyyy-MM-dd HH:mm"); - format.setLenient(false); - format.setTimeZone(java.util.TimeZone.getTimeZone(actualTimeZone)); - // Extract 2-digit year and convert: 00-49 → 2000-2049, 50-99 → 1950-1999 - int yy = Integer.parseInt(normalized.substring(0, 2)); - int yyyy = yy < 50 ? 2000 + yy : 1900 + yy; - String fullDate = yyyy + normalized.substring(2); - date = format.parse(fullDate); - } - else { - throw new RuntimeException("Unsupported DateTime format: " + d); - } - - return date; - } - /* * Java method overloads * diff --git a/src/foam/util/test/DateUtilJSTest.js b/src/foam/util/test/DateUtilJSTest.js index 714d4792a9..a732bde67e 100644 --- a/src/foam/util/test/DateUtilJSTest.js +++ b/src/foam/util/test/DateUtilJSTest.js @@ -74,6 +74,9 @@ foam.CLASS({ this.testParseDateTimeUTC_TwoDigitYearWithTimeNoSeconds(x); this.testParseDateTimeUTC_TwoDigitYearSlidingWindow(x); this.testParseDateTimeUTC_TwoDigitYearUTCBehavior(x); + this.testStrictValidationUtility(x); + this.testStrictValidationMode(x); + this.testNonStrictValidationMode(x); }, function testParseDateString_YYYYMMDD(x) { @@ -244,10 +247,9 @@ foam.CLASS({ }, function testParseDateString_UnsupportedFormat(x) { - // Unsupported format should return MAX_DATE + // In non-strict mode (default), unsupported format returns MAX_DATE var date = foam.util.DateUtil.parseDateString('March 15, 2024'); - var maxDate = foam.util.DateUtil.MAX_DATE; - x.test(date.getTime() === maxDate.getTime(), 'Unsupported format returns MAX_DATE'); + x.test(date.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: unsupported format returns MAX_DATE'); }, function testAdapt_Number(x) { @@ -310,9 +312,9 @@ foam.CLASS({ }, function testAdapt_InvalidString(x) { + // In non-strict mode (default), invalid/unsupported format returns MAX_DATE var date = foam.util.DateUtil.parseDateString('invalid date string'); - var maxDate = foam.util.DateUtil.MAX_DATE; - x.test(date.getTime() === maxDate.getTime(), 'parseDateString(invalid string) returns MAX_DATE'); + x.test(date.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: invalid string returns MAX_DATE'); }, function testParseDateString_LeapYear(x) { @@ -466,24 +468,36 @@ foam.CLASS({ }, function testParseDateString_InvalidFormats(x) { - var maxDate = foam.util.DateUtil.MAX_DATE; - - // Test various invalid formats (don't match any pattern) + // Test various truly unsupported formats - return MAX_DATE in non-strict mode var unsupportedFormats = [ '2024.03.15', // dots instead of dashes/slashes '2024,03,15', // commas - '2024/3/15', // single digit month - '2024/03/5', // single digit day - '24-3-15', // single digits in YY-MM-DD '2024-3', // incomplete date '2024', // year only '03/2024', // month/year only 'abc123' // random text ]; + // In non-strict mode (default), unsupported formats return MAX_DATE unsupportedFormats.forEach(function(format) { var date = foam.util.DateUtil.parseDateString(format); - x.test(date.getTime() === maxDate.getTime(), `Unsupported format "${format}" returns MAX_DATE`); + x.test(date.getTime() === foam.Date.MAX_DATE.getTime(), `Non-strict: format "${format}" returns MAX_DATE`); + }); + + // Test single-digit month/day formats - these ARE supported with separators + // Note: YY-MM-DD format requires opt_name='yymmdd' as it's not in default parser + var singleDigitFormats = [ + { input: '2024/3/15', year: 2024, month: 2, day: 15, desc: 'single digit month' }, + { input: '2024/03/5', year: 2024, month: 2, day: 5, desc: 'single digit day' } + ]; + + singleDigitFormats.forEach(function(testCase) { + var date = foam.util.DateUtil.parseDateString(testCase.input); + var year = date.getUTCFullYear(); + var month = date.getUTCMonth(); + var day = date.getUTCDate(); + x.test(year === testCase.year && month === testCase.month && day === testCase.day, + `Single digit format "${testCase.input}" parses correctly (${testCase.desc})`); }); // Test formats that match a pattern but have invalid date values @@ -506,25 +520,24 @@ foam.CLASS({ }, function testParseDateString_EmptyAndWhitespace(x) { - var maxDate = foam.util.DateUtil.MAX_DATE; - + // In non-strict mode (default), empty/whitespace returns MAX_DATE var emptyDate = foam.util.DateUtil.parseDateString(''); - x.test(emptyDate.getTime() === maxDate.getTime(), 'Empty string returns MAX_DATE'); + x.test(emptyDate.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: empty string returns MAX_DATE'); var wsDate = foam.util.DateUtil.parseDateString(' '); - x.test(wsDate.getTime() === maxDate.getTime(), 'Whitespace returns MAX_DATE'); + x.test(wsDate.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: whitespace returns MAX_DATE'); }, function testAdapt_EmptyString(x) { + // In non-strict mode (default), empty string returns MAX_DATE var date = foam.util.DateUtil.parseDateString(''); - var maxDate = foam.util.DateUtil.MAX_DATE; - x.test(date.getTime() === maxDate.getTime(), 'parseDateString(empty string) returns MAX_DATE'); + x.test(date.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: empty string returns MAX_DATE'); }, function testAdapt_WhitespaceString(x) { + // In non-strict mode (default), whitespace returns MAX_DATE var date = foam.util.DateUtil.parseDateString(' '); - var maxDate = foam.util.DateUtil.MAX_DATE; - x.test(date.getTime() === maxDate.getTime(), 'parseDateString(whitespace) returns MAX_DATE'); + x.test(date.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: whitespace returns MAX_DATE'); }, function testAdapt_AllFormats(x) { @@ -690,16 +703,18 @@ foam.CLASS({ var expectedDate = new Date(2024, 2, 1, 15, 30, 45, 0); // March 1, 2024 15:30:45 local time x.test(invalidDate.getTime() === expectedDate.getTime(), 'Invalid datetime (Feb 30) normalizes to March 1'); - // Invalid hour 25 - DateParser validation should catch this before normalization + // Invalid hour 25 - JavaScript Date normalizes (hour 25 = next day, hour 1) var invalidHour = foam.util.DateUtil.parseDateTime('2024-03-15 25:30:45'); - var maxDate = foam.util.DateUtil.MAX_DATE; - x.test(invalidHour.getTime() === maxDate.getTime(), 'Invalid hour (25) returns MAX_DATE'); + var expectedHour = new Date(2024, 2, 16, 1, 30, 45, 0); // March 16, 2024 01:30:45 local time + x.test(invalidHour.getTime() === expectedHour.getTime(), 'Invalid hour (25) normalizes to next day, hour 1'); + // Invalid minute 60 - grammar rejects minute > 59, so this returns MAX_DATE in non-strict mode var invalidMinute = foam.util.DateUtil.parseDateTime('2024-03-15 15:60:45'); - x.test(invalidMinute.getTime() === maxDate.getTime(), 'Invalid minute (60) returns MAX_DATE'); + x.test(invalidMinute.getTime() === foam.Date.MAX_DATE.getTime(), 'Invalid minute (60) returns MAX_DATE'); + // In non-strict mode (default), unsupported format returns MAX_DATE var unsupportedFormat = foam.util.DateUtil.parseDateTime('March 15, 2024 3:30 PM'); - x.test(unsupportedFormat.getTime() === maxDate.getTime(), 'Unsupported format returns MAX_DATE'); + x.test(unsupportedFormat.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict: unsupported format returns MAX_DATE'); }, function testParseDateTime_PreservesTime(x) { @@ -1756,6 +1771,95 @@ foam.CLASS({ var hour6 = dt6.getUTCHours(); x.test(day6 === 14, `Date boundary crossing - day is 14 (got ${day6})`); x.test(hour6 === 20, `Date boundary crossing - hour is 20 UTC (got ${hour6})`); + }, + + function testStrictValidationUtility(x) { + // Test the setStrictValidation/getStrictValidation utility functions + var originalStrict = foam.util.DateUtil.getStrictValidation(); + + try { + // Test setting to true + foam.util.DateUtil.setStrictValidation(true); + x.test(foam.util.DateUtil.getStrictValidation() === true, 'setStrictValidation(true) sets strict mode'); + + // Test setting to false + foam.util.DateUtil.setStrictValidation(false); + x.test(foam.util.DateUtil.getStrictValidation() === false, 'setStrictValidation(false) sets non-strict mode'); + + // Default should be false + x.test(originalStrict === false, 'Default strict validation should be false'); + } finally { + // Restore original setting + foam.util.DateUtil.setStrictValidation(originalStrict); + } + }, + + function testStrictValidationMode(x) { + // Test that strict mode throws errors for invalid dates + var originalStrict = foam.util.DateUtil.getStrictValidation(); + + try { + foam.util.DateUtil.setStrictValidation(true); + + // Verify strict mode is set + x.test(foam.util.DateUtil.getStrictValidation() === true, 'Strict mode is enabled'); + + // Test that unsupported format throws in strict mode + try { + foam.util.DateUtil.parseDateString('invalid'); + x.test(false, 'Strict mode: unsupported format should throw error'); + } catch (e) { + // validateDate throws "Invalid date" for unparseable input + x.test(e.message.includes('Invalid date'), 'Strict mode: unsupported format throws correct error'); + } + + // Test empty string throws in strict mode + try { + foam.util.DateUtil.parseDateString(''); + x.test(false, 'Strict mode: empty string should throw error'); + } catch (e) { + x.test(e.message.includes('empty or null'), 'Strict mode: empty string throws correct error'); + } + + // Test parseDateTime throws in strict mode + try { + foam.util.DateUtil.parseDateTime('invalid'); + x.test(false, 'Strict mode: parseDateTime with invalid input should throw error'); + } catch (e) { + x.test(e.message.includes('Unsupported DateTime format'), 'Strict mode: parseDateTime throws correct error'); + } + + } finally { + foam.util.DateUtil.setStrictValidation(originalStrict); + } + }, + + function testNonStrictValidationMode(x) { + // Test that non-strict mode handles invalid dates gracefully + var originalStrict = foam.util.DateUtil.getStrictValidation(); + + try { + foam.util.DateUtil.setStrictValidation(false); + + // Verify non-strict mode is set + x.test(foam.util.DateUtil.getStrictValidation() === false, 'Non-strict mode is enabled'); + + // Test that unsupported format returns MAX_DATE in non-strict mode + var invalidDate = foam.util.DateUtil.parseDateString('invalid'); + x.test(invalidDate.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict mode: unsupported format returns MAX_DATE'); + + // Test empty string returns MAX_DATE + var emptyDate = foam.util.DateUtil.parseDateString(''); + x.test(emptyDate.getTime() === foam.Date.MAX_DATE.getTime(), 'Non-strict mode: empty string returns MAX_DATE'); + + // Test that valid formats with invalid values normalize (JavaScript behavior) + var date = foam.util.DateUtil.parseDateString('2024-02-30'); + var expectedDate = new Date(Date.UTC(2024, 2, 1, 12, 0, 0, 0)); // March 1, 2024 + x.test(date.getTime() === expectedDate.getTime(), 'Non-strict mode: Feb 30 normalizes to March 1'); + + } finally { + foam.util.DateUtil.setStrictValidation(originalStrict); + } } ] }); diff --git a/src/foam/util/test/DateUtilTest.js b/src/foam/util/test/DateUtilTest.js index 42cdf6c5d8..e4db331219 100644 --- a/src/foam/util/test/DateUtilTest.js +++ b/src/foam/util/test/DateUtilTest.js @@ -10,6 +10,7 @@ foam.CLASS({ extends: 'foam.core.test.Test', javaImports: [ + 'foam.parse.DateParser', 'foam.util.DateUtil', 'java.time.LocalDate', 'java.time.LocalDateTime', @@ -78,11 +79,17 @@ foam.CLASS({ DateUtilTest_parseDateTime_WithTimezone(); DateUtilTest_parseDateTimeUTC_TwoDigitYearWithTime_Dash(); DateUtilTest_parseDateTimeUTC_TwoDigitYearWithTime_Slash(); - DateUtilTest_parseDateTimeUTC_TwoDigitYear_SlidingWindow(); + DateUtilTest_parseDateTimeUTC_TwoDigitYear_FixedPivot(); DateUtilTest_parseDateTimeUTC_TwoDigitYear_EdgeCases(); DateUtilTest_parseDateTimeUTC_TimeComponentPreservation(); DateUtilTest_format_LocaleDefault_DateOnly(); DateUtilTest_format_LocaleDefault_WithTimezone(); + + // Strict Validation Mode Tests + DateUtilTest_StrictValidation_ThrowsForInvalid(); + DateUtilTest_StrictValidation_ValidDatesWork(); + DateUtilTest_LenientValidation_ReturnsMaxDate(); + DateUtilTest_LenientValidation_ValidDatesWork(); ` }, { @@ -185,12 +192,13 @@ foam.CLASS({ name: 'DateUtilTest_parseDateString_YYMMDD', javaCode: ` try { - // Test 2-digit year using sliding window (50 years back, 50 years forward from current year) - Calendar currentCal = Calendar.getInstance(); - int currentYear = currentCal.get(Calendar.YEAR); + // Test 2-digit year using fixed pivot at 50: + // 00-49 → 2000-2049 + // 50-99 → 1950-1999 + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY - // Test with year 24 (should be 2024 if current year is between 1974-2074) - Date date1 = DateUtil.parseDateString("240315"); + // Test with year 24 → 2024 + Date date1 = DateUtil.parseDateString("240315", "yymmdd"); Calendar cal1 = Calendar.getInstance(); cal1.setTime(date1); int actualYear1 = cal1.get(Calendar.YEAR); @@ -200,20 +208,13 @@ foam.CLASS({ int actualDay1 = cal1.get(Calendar.DAY_OF_MONTH); test(actualDay1 == 15, "YYMMDD format (YY=24) - day is 15 (expected 15, got " + actualDay1 + ")"); - // Test with year 85 - sliding window interpretation - Date date2 = DateUtil.parseDateString("850315"); + // Test with year 85 → 1985 (fixed pivot at 50) + Date date2 = DateUtil.parseDateString("850315", "yymmdd"); Calendar cal2 = Calendar.getInstance(); cal2.setTime(date2); int actualYear2 = cal2.get(Calendar.YEAR); - // Calculate expected year for 85 using sliding window - int currentCentury = (currentYear / 100) * 100; - int expectedYear85 = currentCentury + 85; - if ( expectedYear85 > currentYear + 50 ) { - expectedYear85 = currentCentury - 100 + 85; - } - - test(actualYear2 == expectedYear85, "YYMMDD format (YY=85) - year is " + expectedYear85 + " (expected " + expectedYear85 + ", got " + actualYear2 + ")"); + test(actualYear2 == 1985, "YYMMDD format (YY=85) - year is 1985 (expected 1985, got " + actualYear2 + ")"); int actualMonth2 = cal2.get(Calendar.MONTH); test(actualMonth2 == 2, "YYMMDD format (YY=85) - month is March (2) (expected 2, got " + actualMonth2 + ")"); int actualDay2 = cal2.get(Calendar.DAY_OF_MONTH); @@ -227,29 +228,25 @@ foam.CLASS({ name: 'DateUtilTest_parseDateString_YY_MM_DD', javaCode: ` try { - Calendar currentCal = Calendar.getInstance(); - int currentYear = currentCal.get(Calendar.YEAR); + // Test 2-digit year using fixed pivot at 50: + // 00-49 → 2000-2049 + // 50-99 → 1950-1999 + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY - // Test with slash separator - Date date1 = DateUtil.parseDateString("24/03/15"); + // Test with slash separator - year 24 → 2024 + Date date1 = DateUtil.parseDateString("24/03/15", "yymmdd"); Calendar cal1 = Calendar.getInstance(); cal1.setTime(date1); int actualYear1 = cal1.get(Calendar.YEAR); test(actualYear1 == 2024, "YY/MM/DD format - year is 2024 (expected 2024, got " + actualYear1 + ")"); - // Test with dash separator - sliding window interpretation - Date date2 = DateUtil.parseDateString("85-03-15"); + // Test with dash separator - year 85 → 1985 (fixed pivot at 50) + Date date2 = DateUtil.parseDateString("85-03-15", "yymmdd"); Calendar cal2 = Calendar.getInstance(); cal2.setTime(date2); int actualYear2 = cal2.get(Calendar.YEAR); - int currentCentury = (currentYear / 100) * 100; - int expectedYear85 = currentCentury + 85; - if ( expectedYear85 > currentYear + 50 ) { - expectedYear85 = currentCentury - 100 + 85; - } - - test(actualYear2 == expectedYear85, "YY-MM-DD format - year is " + expectedYear85 + " (expected " + expectedYear85 + ", got " + actualYear2 + ")"); + test(actualYear2 == 1985, "YY-MM-DD format - year is 1985 (expected 1985, got " + actualYear2 + ")"); } catch ( Exception e ) { test(false, "YY/MM/DD or YY-MM-DD format should not throw exception: " + e.getMessage()); } @@ -259,27 +256,27 @@ foam.CLASS({ name: 'DateUtilTest_parseDateString_InvalidDate', javaCode: ` try { - // Test invalid date like February 30th + // Test invalid date like February 30th - Calendar normalizes to March 1st (Feb has 29 days in 2024) Date date = DateUtil.parseDateString("2024-02-30"); - test(false, "Invalid date (Feb 30) should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Cannot parse invalid date"), "Invalid date throws correct error message"); + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + int actualYear = cal.get(Calendar.YEAR); + int actualMonth = cal.get(Calendar.MONTH); + int actualDay = cal.get(Calendar.DAY_OF_MONTH); + test(actualYear == 2024, "Invalid date (Feb 30) - year normalized to 2024 (expected 2024, got " + actualYear + ")"); + test(actualMonth == 2, "Invalid date (Feb 30) - month normalized to March (2) (expected 2, got " + actualMonth + ")"); + test(actualDay == 1, "Invalid date (Feb 30) - day normalized to 1 (expected 1, got " + actualDay + ")"); } catch ( Exception e ) { - test(false, "Invalid date should throw RuntimeException, not " + e.getClass().getSimpleName()); + test(false, "Invalid date should normalize, not throw exception: " + e.getMessage()); } ` }, { name: 'DateUtilTest_parseDateString_UnsupportedFormat', javaCode: ` - try { - Date date = DateUtil.parseDateString("March 15, 2024"); - test(false, "Unsupported format should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Unsupported Date format"), "Unsupported format throws correct error message"); - } catch ( Exception e ) { - test(false, "Unsupported format should throw RuntimeException, not " + e.getClass().getSimpleName()); - } + // Default is non-strict mode - unsupported formats return MAX_DATE instead of throwing + Date date = DateUtil.parseDateString("March 15, 2024"); + test(date.equals(DateParser.MAX_DATE), "Unsupported format returns MAX_DATE in lenient mode"); ` }, { @@ -360,8 +357,9 @@ foam.CLASS({ { name: 'DateUtilTest_adapt_InvalidString', javaCode: ` + // Default is non-strict mode - invalid string returns MAX_DATE Date date = DateUtil.adapt("invalid date string"); - test(date == DateUtil.MAX_DATE, "adapt(invalid string) returns MAX_DATE"); + test(date.equals(DateParser.MAX_DATE), "adapt(invalid string) returns MAX_DATE in lenient mode"); ` }, { @@ -531,13 +529,18 @@ foam.CLASS({ name: 'DateUtilTest_parseDateString_NonLeapYear', javaCode: ` try { - // Test invalid Feb 29 in non-leap year + // Test Feb 29 in non-leap year - Calendar normalizes to March 1st Date date = DateUtil.parseDateString("2023-02-29"); - test(false, "Non-leap year - Feb 29, 2023 should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Cannot parse invalid date"), "Non-leap year Feb 29 throws error"); + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + int actualYear = cal.get(Calendar.YEAR); + int actualMonth = cal.get(Calendar.MONTH); + int actualDay = cal.get(Calendar.DAY_OF_MONTH); + test(actualYear == 2023, "Non-leap year Feb 29 - year normalized to 2023 (expected 2023, got " + actualYear + ")"); + test(actualMonth == 2, "Non-leap year Feb 29 - month normalized to March (2) (expected 2, got " + actualMonth + ")"); + test(actualDay == 1, "Non-leap year Feb 29 - day normalized to 1 (expected 1, got " + actualDay + ")"); } catch ( Exception e ) { - test(false, "Non-leap year should throw RuntimeException: " + e.getMessage()); + test(false, "Non-leap year Feb 29 should normalize, not throw exception: " + e.getMessage()); } ` }, @@ -590,19 +593,31 @@ foam.CLASS({ test(false, "Valid month boundaries should not throw exception: " + e.getMessage()); } - // Test invalid dates + // Test invalid dates - Calendar normalizes them try { - DateUtil.parseDateString("2024-04-31"); - test(false, "Apr 31 should throw exception"); + // Apr 31 normalizes to May 1 + Date apr31 = DateUtil.parseDateString("2024-04-31"); + Calendar cal3 = Calendar.getInstance(); + cal3.setTime(apr31); + int actualMonth3 = cal3.get(Calendar.MONTH); + int actualDay3 = cal3.get(Calendar.DAY_OF_MONTH); + test(actualMonth3 == 4, "Apr 31 normalized to May (4) (expected 4, got " + actualMonth3 + ")"); + test(actualDay3 == 1, "Apr 31 normalized to day 1 (expected 1, got " + actualDay3 + ")"); } catch ( RuntimeException e ) { - test(true, "Apr 31 is invalid"); + test(false, "Apr 31 should normalize, not throw exception: " + e.getMessage()); } try { - DateUtil.parseDateString("2024-02-31"); - test(false, "Feb 31 should throw exception"); + // Feb 31 normalizes to Mar 3 (or Mar 2 in leap year) + Date feb31 = DateUtil.parseDateString("2024-02-31"); + Calendar cal4 = Calendar.getInstance(); + cal4.setTime(feb31); + int actualMonth4 = cal4.get(Calendar.MONTH); + int actualDay4 = cal4.get(Calendar.DAY_OF_MONTH); + test(actualMonth4 == 2, "Feb 31 normalized to March (2) (expected 2, got " + actualMonth4 + ")"); + test(actualDay4 == 2, "Feb 31 normalized to day 2 (expected 2, got " + actualDay4 + ")"); } catch ( RuntimeException e ) { - test(true, "Feb 31 is invalid"); + test(false, "Feb 31 should normalize, not throw exception: " + e.getMessage()); } ` }, @@ -692,54 +707,38 @@ foam.CLASS({ name: 'DateUtilTest_parseDateString_TwoDigitYearBoundary', javaCode: ` try { - // Test 2-digit year using sliding window (50 years back, 50 years forward) - Calendar currentCal = Calendar.getInstance(); - int currentYear = currentCal.get(Calendar.YEAR); - int currentCentury = (currentYear / 100) * 100; + // Test 2-digit year using fixed pivot at 50: + // 00-49 → 2000-2049 + // 50-99 → 1950-1999 + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY - // Test year 49 - Date date1 = DateUtil.parseDateString("49-12-31"); + // Test year 49 → 2049 + Date date1 = DateUtil.parseDateString("49-12-31", "yymmdd"); Calendar cal1 = Calendar.getInstance(); cal1.setTime(date1); int actualYear1 = cal1.get(Calendar.YEAR); - int expected1 = currentCentury + 49; - if ( expected1 > currentYear + 50 ) { - expected1 = currentCentury - 100 + 49; - } - test(actualYear1 == expected1, "2-digit year 49 becomes " + expected1 + " (expected " + expected1 + ", got " + actualYear1 + ")"); + test(actualYear1 == 2049, "2-digit year 49 becomes 2049 (expected 2049, got " + actualYear1 + ")"); - // Test year 00 - Date date2 = DateUtil.parseDateString("00-01-01"); + // Test year 00 → 2000 + Date date2 = DateUtil.parseDateString("00-01-01", "yymmdd"); Calendar cal2 = Calendar.getInstance(); cal2.setTime(date2); int actualYear2 = cal2.get(Calendar.YEAR); - int expected2 = currentCentury + 0; - if ( expected2 > currentYear + 50 ) { - expected2 = currentCentury - 100 + 0; - } - test(actualYear2 == expected2, "2-digit year 00 becomes " + expected2 + " (expected " + expected2 + ", got " + actualYear2 + ")"); + test(actualYear2 == 2000, "2-digit year 00 becomes 2000 (expected 2000, got " + actualYear2 + ")"); - // Test year 50 - Date date3 = DateUtil.parseDateString("50-01-01"); + // Test year 50 → 1950 (pivot point) + Date date3 = DateUtil.parseDateString("50-01-01", "yymmdd"); Calendar cal3 = Calendar.getInstance(); cal3.setTime(date3); int actualYear3 = cal3.get(Calendar.YEAR); - int expected3 = currentCentury + 50; - if ( expected3 > currentYear + 50 ) { - expected3 = currentCentury - 100 + 50; - } - test(actualYear3 == expected3, "2-digit year 50 becomes " + expected3 + " (expected " + expected3 + ", got " + actualYear3 + ")"); + test(actualYear3 == 1950, "2-digit year 50 becomes 1950 (expected 1950, got " + actualYear3 + ")"); - // Test year 99 - Date date4 = DateUtil.parseDateString("99-12-31"); + // Test year 99 → 1999 + Date date4 = DateUtil.parseDateString("99-12-31", "yymmdd"); Calendar cal4 = Calendar.getInstance(); cal4.setTime(date4); int actualYear4 = cal4.get(Calendar.YEAR); - int expected4 = currentCentury + 99; - if ( expected4 > currentYear + 50 ) { - expected4 = currentCentury - 100 + 99; - } - test(actualYear4 == expected4, "2-digit year 99 becomes " + expected4 + " (expected " + expected4 + ", got " + actualYear4 + ")"); + test(actualYear4 == 1999, "2-digit year 99 becomes 1999 (expected 1999, got " + actualYear4 + ")"); } catch ( Exception e ) { test(false, "2-digit year boundary tests should not throw exception: " + e.getMessage()); } @@ -748,92 +747,100 @@ foam.CLASS({ { name: 'DateUtilTest_parseDateString_InvalidFormats', javaCode: ` + // Default is non-strict mode - unsupported formats return MAX_DATE + // Test various invalid formats (don't match any pattern) + // Note: Single-digit month/day with separators (e.g., 2024/3/15) ARE supported by grammar String[] unsupportedFormats = { "2024.03.15", // dots instead of dashes/slashes "2024,03,15", // commas - "2024/3/15", // single digit month - "2024/03/5", // single digit day - "24-3-15", // single digits in YY-MM-DD + "24-3-15", // single digits in YY-MM-DD (requires opt_name='yymmdd') "2024-3", // incomplete date "2024", // year only "03/2024", // month/year only - "abc123", // random text - "12345678901" // too many digits + "abc123" // random text }; for ( String format : unsupportedFormats ) { - try { - DateUtil.parseDateString(format); - test(false, "Unsupported format \\"" + format + "\\" should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Unsupported Date format"), "Format \\"" + format + "\\" throws \\"Unsupported Date format\\""); - } + Date result = DateUtil.parseDateString(format); + test(result.equals(DateParser.MAX_DATE), "Format \\"" + format + "\\" returns MAX_DATE in lenient mode"); } - // Test formats that match a pattern but have invalid date values + // Test single-digit formats that ARE supported by the grammar + String[] singleDigitFormats = { + "2024/3/15", // single digit month - valid + "2024/03/5", // single digit day - valid + "2024-3-15", // single digit month with dash - valid + "2024-03-5" // single digit day with dash - valid + }; + + for ( String format : singleDigitFormats ) { + Date date = DateUtil.parseDateString(format); + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + test(cal.get(Calendar.YEAR) == 2024, "Single-digit format \\"" + format + "\\" - year is 2024"); + test(cal.get(Calendar.MONTH) == 2, "Single-digit format \\"" + format + "\\" - month is March (2)"); + test(cal.get(Calendar.DAY_OF_MONTH) == 15 || cal.get(Calendar.DAY_OF_MONTH) == 5, + "Single-digit format \\"" + format + "\\" - day is valid"); + } + + // Test formats that match a pattern but have invalid date values - Calendar normalizes them String[] invalidDates = { - "15-03-2024", // DD-MM-YYYY looks like MM-DD-YYYY with month=15 (invalid) - "13-32-2024", // month=13, day=32 (both invalid) - "00-01-2024", // month=00 (invalid) - "01-00-2024" // day=00 (invalid) + "15-03-2024", // DD-MM-YYYY looks like MM-DD-YYYY with month=15 → normalized (Apr 2024 or similar) + "13-32-2024", // month=13, day=32 → normalized (Feb 2025 or similar) + "00-01-2024", // month=00 → normalized (Dec 2023) + "01-00-2024" // day=00 → normalized (Dec 31, 2023) }; + // These dates should be normalized by Calendar, not throw exceptions for ( String format : invalidDates ) { - try { - DateUtil.parseDateString(format); - test(false, "Invalid date \\"" + format + "\\" should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Cannot parse invalid date"), "Date \\"" + format + "\\" throws \\"Cannot parse invalid date\\""); - } + Date date = DateUtil.parseDateString(format); + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + // Just verify we got a valid normalized date without exception + test(date != null, "Date \\"" + format + "\\" normalized to valid date"); } ` }, { name: 'DateUtilTest_parseDateString_EmptyAndWhitespace', javaCode: ` - try { - DateUtil.parseDateString(""); - test(false, "Empty string should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Unsupported Date format"), "Empty string throws error"); - } + // Default is non-strict mode - empty/whitespace returns MAX_DATE + Date emptyResult = DateUtil.parseDateString(""); + test(emptyResult.equals(DateParser.MAX_DATE), "Empty string returns MAX_DATE in lenient mode"); - try { - DateUtil.parseDateString(" "); - test(false, "Whitespace string should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Unsupported Date format"), "Whitespace throws error"); - } + Date whitespaceResult = DateUtil.parseDateString(" "); + test(whitespaceResult.equals(DateParser.MAX_DATE), "Whitespace string returns MAX_DATE in lenient mode"); ` }, { name: 'DateUtilTest_adapt_EmptyString', javaCode: ` + // Default is non-strict mode - empty string returns MAX_DATE Date date = DateUtil.adapt(""); - test(date == DateUtil.MAX_DATE, "adapt(empty string) returns MAX_DATE"); + test(date.equals(DateParser.MAX_DATE), "adapt(empty string) returns MAX_DATE in lenient mode"); ` }, { name: 'DateUtilTest_adapt_WhitespaceString', javaCode: ` + // Default is non-strict mode - whitespace string returns MAX_DATE Date date = DateUtil.adapt(" "); - test(date == DateUtil.MAX_DATE, "adapt(whitespace) returns MAX_DATE"); + test(date.equals(DateParser.MAX_DATE), "adapt(whitespace) returns MAX_DATE in lenient mode"); ` }, { name: 'DateUtilTest_adapt_AllFormats', javaCode: ` + // NOTE: YYMMDD formats (24-03-15, 24/03/15, 240315) are NOT included here + // because they're ambiguous with MMDDYY and require explicit opt_name='yymmdd' String[] formats = { "2024-03-15", "2024/03/15", "20240315", "03-15-2024", "03/15/2024", - "03152024", - "24-03-15", - "24/03/15", - "240315" + "03152024" }; for ( String format : formats ) { @@ -961,29 +968,33 @@ foam.CLASS({ { name: 'DateUtilTest_parseDateTime_InvalidFormats', javaCode: ` - // Test invalid datetime + // Test invalid datetime - should normalize try { - DateUtil.parseDateTime("2024-02-30 15:30:45"); - test(false, "Invalid datetime (Feb 30) should throw exception"); + Date dt = DateUtil.parseDateTime("2024-02-30 15:30:45"); + Calendar cal = Calendar.getInstance(); + cal.setTime(dt); + // Feb 30 normalizes to Mar 1 + test(cal.get(Calendar.MONTH) == 2, "Invalid datetime (Feb 30) normalizes to March (2)"); + test(cal.get(Calendar.DAY_OF_MONTH) == 1, "Invalid datetime (Feb 30) normalizes to day 1"); + test(cal.get(Calendar.HOUR_OF_DAY) == 15, "Time component preserved (hour 15)"); } catch ( RuntimeException e ) { - test(e.getMessage().contains("Cannot parse invalid datetime"), "Invalid datetime throws error"); + test(false, "Invalid datetime should normalize, not throw exception: " + e.getMessage()); } - // Test invalid hour + // Test invalid hour - should normalize (hour 25 = next day, hour 1) try { - DateUtil.parseDateTime("2024-03-15 25:30:45"); - test(false, "Invalid hour (25) should throw exception"); + Date dt = DateUtil.parseDateTime("2024-03-15 25:30:45"); + Calendar cal = Calendar.getInstance(); + cal.setTime(dt); + test(cal.get(Calendar.DAY_OF_MONTH) == 16, "Invalid hour (25) normalizes to next day (16)"); + test(cal.get(Calendar.HOUR_OF_DAY) == 1, "Invalid hour (25) normalizes to hour 1"); } catch ( RuntimeException e ) { - test(e.getMessage().contains("Cannot parse invalid datetime"), "Invalid hour throws error"); + test(false, "Invalid hour should normalize, not throw exception: " + e.getMessage()); } - // Test unsupported format - try { - DateUtil.parseDateTime("March 15, 2024 3:30 PM"); - test(false, "Unsupported format should throw exception"); - } catch ( RuntimeException e ) { - test(e.getMessage().contains("Unsupported DateTime format"), "Unsupported format throws error"); - } + // Test unsupported format - in lenient mode returns MAX_DATE + Date unsupportedResult = DateUtil.parseDateTime("March 15, 2024 3:30 PM"); + test(unsupportedResult.equals(DateParser.MAX_DATE), "Unsupported format returns MAX_DATE in lenient mode"); ` }, { @@ -1248,9 +1259,10 @@ foam.CLASS({ try { // Test 2-digit year with time (dash separator) // Format: YY-MM-DD HH:MM:SS + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY // Test 1: 24-03-15 14:30:45 → March 15, 2024 14:30:45 UTC - Date dt1 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45"); + Date dt1 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45", "yymmdd"); Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal1.setTime(dt1); @@ -1261,21 +1273,12 @@ foam.CLASS({ test(cal1.get(Calendar.MINUTE) == 30, "YY-MM-DD HH:MM:SS - Minute is 30"); test(cal1.get(Calendar.SECOND) == 45, "YY-MM-DD HH:MM:SS - Second is 45"); - // Test 2: 99-03-15 14:30:45 → March 15, 1999 14:30:45 UTC (using sliding window) - Date dt2 = DateUtil.parseDateTimeUTC("99-03-15 14:30:45"); + // Test 2: 99-03-15 14:30:45 → March 15, 1999 14:30:45 UTC (fixed pivot: 99 → 1999) + Date dt2 = DateUtil.parseDateTimeUTC("99-03-15 14:30:45", "yymmdd"); Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal2.setTime(dt2); - // Calculate expected year using sliding window - Calendar currentCal = Calendar.getInstance(); - int currentYear = currentCal.get(Calendar.YEAR); - int currentCentury = (currentYear / 100) * 100; - int expectedYear99 = currentCentury + 99; - if ( expectedYear99 > currentYear + 50 ) { - expectedYear99 = currentCentury - 100 + 99; - } - - test(cal2.get(Calendar.YEAR) == expectedYear99, "YY-MM-DD HH:MM:SS (99) - Year is " + expectedYear99); + test(cal2.get(Calendar.YEAR) == 1999, "YY-MM-DD HH:MM:SS (99) - Year is 1999"); test(cal2.get(Calendar.MONTH) == 2, "YY-MM-DD HH:MM:SS (99) - Month is March (2)"); test(cal2.get(Calendar.DAY_OF_MONTH) == 15, "YY-MM-DD HH:MM:SS (99) - Day is 15"); test(cal2.get(Calendar.HOUR_OF_DAY) == 14, "YY-MM-DD HH:MM:SS (99) - Hour is 14 UTC"); @@ -1283,7 +1286,7 @@ foam.CLASS({ test(cal2.get(Calendar.SECOND) == 45, "YY-MM-DD HH:MM:SS (99) - Second is 45"); // Test 3: Without seconds - 24-03-15 14:30 → March 15, 2024 14:30:00 UTC - Date dt3 = DateUtil.parseDateTimeUTC("24-03-15 14:30"); + Date dt3 = DateUtil.parseDateTimeUTC("24-03-15 14:30", "yymmdd"); Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal3.setTime(dt3); @@ -1303,9 +1306,10 @@ foam.CLASS({ try { // Test 2-digit year with time (slash separator) // Format: YY/MM/DD HH:MM:SS + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY // Test 1: 24/03/15 08:15:30 → March 15, 2024 08:15:30 UTC - Date dt1 = DateUtil.parseDateTimeUTC("24/03/15 08:15:30"); + Date dt1 = DateUtil.parseDateTimeUTC("24/03/15 08:15:30", "yymmdd"); Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal1.setTime(dt1); @@ -1317,7 +1321,7 @@ foam.CLASS({ test(cal1.get(Calendar.SECOND) == 30, "YY/MM/DD HH:MM:SS - Second is 30"); // Test 2: Without seconds - 24/03/15 08:15 → March 15, 2024 08:15:00 UTC - Date dt2 = DateUtil.parseDateTimeUTC("24/03/15 08:15"); + Date dt2 = DateUtil.parseDateTimeUTC("24/03/15 08:15", "yymmdd"); Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal2.setTime(dt2); @@ -1327,7 +1331,7 @@ foam.CLASS({ test(cal2.get(Calendar.SECOND) == 0, "YY/MM/DD HH:MM - Second defaults to 0"); // Test 3: With timezone Z - 24/03/15 08:15:30Z → March 15, 2024 08:15:30 UTC - Date dt3 = DateUtil.parseDateTimeUTC("24/03/15 08:15:30Z"); + Date dt3 = DateUtil.parseDateTimeUTC("24/03/15 08:15:30Z", "yymmdd"); Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal3.setTime(dt3); @@ -1342,70 +1346,54 @@ foam.CLASS({ ` }, { - name: 'DateUtilTest_parseDateTimeUTC_TwoDigitYear_SlidingWindow', + name: 'DateUtilTest_parseDateTimeUTC_TwoDigitYear_FixedPivot', javaCode: ` try { - // Test 2-digit year sliding window behavior with time - // Years 00-49 should map to 2000-2049 - // Years 50-99 should map to 1950-1999 - - Calendar currentCal = Calendar.getInstance(); - int currentYear = currentCal.get(Calendar.YEAR); - int currentCentury = (currentYear / 100) * 100; - - // Test boundary at 00 - Date dt00 = DateUtil.parseDateTimeUTC("00-01-01 12:00:00"); + // Test 2-digit year fixed pivot behavior with time + // Fixed pivot at 50: + // Years 00-49 map to 2000-2049 + // Years 50-99 map to 1950-1999 + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY + + // Test boundary at 00 → 2000 + Date dt00 = DateUtil.parseDateTimeUTC("00-01-01 12:00:00", "yymmdd"); Calendar cal00 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal00.setTime(dt00); - int expected00 = currentCentury; - if ( expected00 > currentYear + 50 ) { - expected00 = currentCentury - 100; - } - test(cal00.get(Calendar.YEAR) == expected00, "YY=00 with time maps to " + expected00 + " (got " + cal00.get(Calendar.YEAR) + ")"); + test(cal00.get(Calendar.YEAR) == 2000, "YY=00 with time maps to 2000 (got " + cal00.get(Calendar.YEAR) + ")"); test(cal00.get(Calendar.HOUR_OF_DAY) == 12, "YY=00 - Hour is 12"); - // Test boundary at 25 (should be in 2000s range) - Date dt25 = DateUtil.parseDateTimeUTC("25-06-15 15:30:45"); + // Test year 25 → 2025 (in 2000s range) + Date dt25 = DateUtil.parseDateTimeUTC("25-06-15 15:30:45", "yymmdd"); Calendar cal25 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal25.setTime(dt25); - int expected25 = currentCentury + 25; - if ( expected25 > currentYear + 50 ) { - expected25 = currentCentury - 100 + 25; - } - test(cal25.get(Calendar.YEAR) == expected25, "YY=25 with time maps to " + expected25 + " (got " + cal25.get(Calendar.YEAR) + ")"); + test(cal25.get(Calendar.YEAR) == 2025, "YY=25 with time maps to 2025 (got " + cal25.get(Calendar.YEAR) + ")"); test(cal25.get(Calendar.HOUR_OF_DAY) == 15, "YY=25 - Hour is 15"); test(cal25.get(Calendar.MINUTE) == 30, "YY=25 - Minute is 30"); - // Test boundary at 49 (last year in 2000s range) - fixed pivot at 50 - Date dt49 = DateUtil.parseDateTimeUTC("49-12-31 23:59:59"); + // Test boundary at 49 → 2049 (last year in 2000s range) + Date dt49 = DateUtil.parseDateTimeUTC("49-12-31 23:59:59", "yymmdd"); Calendar cal49 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal49.setTime(dt49); - // Fixed pivot: 00-49 → 2000-2049, 50-99 → 1950-1999 - int expected49 = 2049; - test(cal49.get(Calendar.YEAR) == expected49, "YY=49 with time maps to " + expected49 + " (got " + cal49.get(Calendar.YEAR) + ")"); + test(cal49.get(Calendar.YEAR) == 2049, "YY=49 with time maps to 2049 (got " + cal49.get(Calendar.YEAR) + ")"); test(cal49.get(Calendar.HOUR_OF_DAY) == 23, "YY=49 - Hour is 23"); test(cal49.get(Calendar.SECOND) == 59, "YY=49 - Second is 59"); - // Test boundary at 50 (first year in 1900s range) - fixed pivot at 50 - Date dt50 = DateUtil.parseDateTimeUTC("50-01-01 00:00:00"); + // Test boundary at 50 → 1950 (pivot point - first year in 1900s range) + Date dt50 = DateUtil.parseDateTimeUTC("50-01-01 00:00:00", "yymmdd"); Calendar cal50 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal50.setTime(dt50); - // Fixed pivot: 00-49 → 2000-2049, 50-99 → 1950-1999 - int expected50 = 1950; - test(cal50.get(Calendar.YEAR) == expected50, "YY=50 with time maps to " + expected50 + " (got " + cal50.get(Calendar.YEAR) + ")"); + test(cal50.get(Calendar.YEAR) == 1950, "YY=50 with time maps to 1950 (got " + cal50.get(Calendar.YEAR) + ")"); test(cal50.get(Calendar.HOUR_OF_DAY) == 0, "YY=50 - Hour is 0"); - // Test at 75 (should be in 1900s range) - fixed pivot at 50 - Date dt75 = DateUtil.parseDateTimeUTC("75-06-15 18:45:30"); + // Test year 75 → 1975 (in 1900s range) + Date dt75 = DateUtil.parseDateTimeUTC("75-06-15 18:45:30", "yymmdd"); Calendar cal75 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal75.setTime(dt75); - // Fixed pivot: 00-49 → 2000-2049, 50-99 → 1950-1999 - int expected75 = 1975; - test(cal75.get(Calendar.YEAR) == expected75, "YY=75 with time maps to " + expected75 + " (got " + cal75.get(Calendar.YEAR) + ")"); + test(cal75.get(Calendar.YEAR) == 1975, "YY=75 with time maps to 1975 (got " + cal75.get(Calendar.YEAR) + ")"); test(cal75.get(Calendar.HOUR_OF_DAY) == 18, "YY=75 - Hour is 18"); } catch ( Exception e ) { - test(false, "Should handle 2-digit year sliding window with time: " + e.getMessage()); + test(false, "Should handle 2-digit year fixed pivot with time: " + e.getMessage()); } ` }, @@ -1414,9 +1402,10 @@ foam.CLASS({ javaCode: ` try { // Test edge cases for 2-digit year with time and timezone + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY // Test 1: With timezone Z - Date dt1 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45Z"); + Date dt1 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45Z", "yymmdd"); Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal1.setTime(dt1); @@ -1426,7 +1415,7 @@ foam.CLASS({ // Test 2: With positive timezone offset +05:30 // 24-03-15 14:30:45+05:30 → March 15, 2024 09:00:45 UTC - Date dt2 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45+05:30"); + Date dt2 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45+05:30", "yymmdd"); Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal2.setTime(dt2); @@ -1437,7 +1426,7 @@ foam.CLASS({ // Test 3: With negative timezone offset -08:00 // 24-03-15 14:30:45-08:00 → March 15, 2024 22:30:45 UTC - Date dt3 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45-08:00"); + Date dt3 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45-08:00", "yymmdd"); Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal3.setTime(dt3); @@ -1447,7 +1436,7 @@ foam.CLASS({ test(cal3.get(Calendar.SECOND) == 45, "YY-MM-DD HH:MM:SS-08:00 - Second is 45"); // Test 4: Timezone offset without colon +0530 - Date dt4 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45+0530"); + Date dt4 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45+0530", "yymmdd"); Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal4.setTime(dt4); @@ -1455,7 +1444,7 @@ foam.CLASS({ test(cal4.get(Calendar.HOUR_OF_DAY) == 9, "YY-MM-DD HH:MM:SS+0530 - Hour is 9 UTC"); // Test 5: With slash separator and timezone - Date dt5 = DateUtil.parseDateTimeUTC("24/03/15 14:30:45+05:30"); + Date dt5 = DateUtil.parseDateTimeUTC("24/03/15 14:30:45+05:30", "yymmdd"); Calendar cal5 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal5.setTime(dt5); @@ -1463,7 +1452,7 @@ foam.CLASS({ test(cal5.get(Calendar.HOUR_OF_DAY) == 9, "YY/MM/DD HH:MM:SS+05:30 - Hour is 9 UTC"); // Test 6: Leap year with 2-digit year - 24-02-29 14:30:45 - Date dt6 = DateUtil.parseDateTimeUTC("24-02-29 14:30:45"); + Date dt6 = DateUtil.parseDateTimeUTC("24-02-29 14:30:45", "yymmdd"); Calendar cal6 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal6.setTime(dt6); @@ -1482,9 +1471,10 @@ foam.CLASS({ javaCode: ` try { // Test that time components are preserved correctly across different formats + // NOTE: YYMMDD requires opt_name because it's ambiguous with MMDDYY // Test 1: Midnight - Date dt1 = DateUtil.parseDateTimeUTC("24-03-15 00:00:00"); + Date dt1 = DateUtil.parseDateTimeUTC("24-03-15 00:00:00", "yymmdd"); Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal1.setTime(dt1); test(cal1.get(Calendar.HOUR_OF_DAY) == 0, "Midnight - Hour is 0"); @@ -1492,7 +1482,7 @@ foam.CLASS({ test(cal1.get(Calendar.SECOND) == 0, "Midnight - Second is 0"); // Test 2: Noon - Date dt2 = DateUtil.parseDateTimeUTC("24/03/15 12:00:00"); + Date dt2 = DateUtil.parseDateTimeUTC("24/03/15 12:00:00", "yymmdd"); Calendar cal2 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal2.setTime(dt2); test(cal2.get(Calendar.HOUR_OF_DAY) == 12, "Noon - Hour is 12"); @@ -1500,7 +1490,7 @@ foam.CLASS({ test(cal2.get(Calendar.SECOND) == 0, "Noon - Second is 0"); // Test 3: End of day - Date dt3 = DateUtil.parseDateTimeUTC("24-12-31 23:59:59"); + Date dt3 = DateUtil.parseDateTimeUTC("24-12-31 23:59:59", "yymmdd"); Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal3.setTime(dt3); test(cal3.get(Calendar.HOUR_OF_DAY) == 23, "End of day - Hour is 23"); @@ -1513,7 +1503,7 @@ foam.CLASS({ // Test 5: Time preservation across timezone conversion // Local time 14:30:45+05:30 → UTC 09:00:45 - Date dt5 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45+05:30"); + Date dt5 = DateUtil.parseDateTimeUTC("24-03-15 14:30:45+05:30", "yymmdd"); Calendar cal5 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal5.setTime(dt5); @@ -1524,7 +1514,7 @@ foam.CLASS({ // Test 6: Date rollover due to timezone // 2024-03-15 23:30:00-08:00 → 2024-03-16 07:30:00 UTC (next day) - Date dt6 = DateUtil.parseDateTimeUTC("24-03-15 23:30:00-08:00"); + Date dt6 = DateUtil.parseDateTimeUTC("24-03-15 23:30:00-08:00", "yymmdd"); Calendar cal6 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); cal6.setTime(dt6); @@ -1608,6 +1598,180 @@ foam.CLASS({ System.out.println("Consistency check - format(date, UTC): " + formatted1); System.out.println("Consistency check - formatWithTimeControl(date, true, UTC): " + formattedControlFirst); ` + }, + + // ========== Strict Validation Mode Tests ========== + + { + name: 'DateUtilTest_StrictValidation_ThrowsForInvalid', + javaCode: ` + // Enable strict validation mode + DateUtil.setStrictValidation(true); + + try { + // Test 1: Invalid format should throw + try { + DateUtil.parseDateString("not-a-date"); + test(false, "StrictMode: invalid format should throw"); + } catch ( RuntimeException e ) { + test(e.getMessage().contains("Unsupported Date format"), "StrictMode: invalid format throws correct exception"); + } + + // Test 2: Empty string should throw + try { + DateUtil.parseDateString(""); + test(false, "StrictMode: empty string should throw"); + } catch ( RuntimeException e ) { + test(e.getMessage().contains("empty or null"), "StrictMode: empty string throws correct exception"); + } + + // Test 3: Null should throw + try { + DateUtil.parseDateString(null); + test(false, "StrictMode: null should throw"); + } catch ( RuntimeException e ) { + test(e.getMessage().contains("empty or null"), "StrictMode: null throws correct exception"); + } + + // Test 4: parseDateTime with invalid input should throw + try { + DateUtil.parseDateTime("garbage"); + test(false, "StrictMode parseDateTime: should throw for invalid input"); + } catch ( RuntimeException e ) { + test(true, "StrictMode parseDateTime: throws for invalid input"); + } + + // Test 5: parseDateTimeUTC with invalid input should throw + try { + DateUtil.parseDateTimeUTC("invalid"); + test(false, "StrictMode parseDateTimeUTC: should throw for invalid input"); + } catch ( RuntimeException e ) { + test(true, "StrictMode parseDateTimeUTC: throws for invalid input"); + } + + // Test 6: adapt with invalid string should throw + try { + DateUtil.adapt("invalid date string"); + test(false, "StrictMode adapt: should throw for invalid string"); + } catch ( RuntimeException e ) { + test(true, "StrictMode adapt: throws for invalid string"); + } + + } finally { + // Reset to default lenient mode + DateUtil.setStrictValidation(false); + } + ` + }, + { + name: 'DateUtilTest_StrictValidation_ValidDatesWork', + javaCode: ` + // Enable strict validation mode + DateUtil.setStrictValidation(true); + + try { + // Test that valid dates still work in strict mode + Date date1 = DateUtil.parseDateString("2025-01-15"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "StrictMode: valid date parses - year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "StrictMode: valid date parses - month Jan"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "StrictMode: valid date parses - day 15"); + + // Test parseDateTime + Date date2 = DateUtil.parseDateTime("2025-01-15T14:30:45"); + Calendar cal2 = Calendar.getInstance(); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "StrictMode: valid datetime parses - year 2025"); + test(cal2.get(Calendar.HOUR_OF_DAY) == 14, "StrictMode: valid datetime parses - hour 14"); + + // Test parseDateTimeUTC + Date date3 = DateUtil.parseDateTimeUTC("2025-01-15T14:30:45Z"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2025, "StrictMode: valid UTC datetime parses - year 2025"); + test(cal3.get(Calendar.HOUR_OF_DAY) == 14, "StrictMode: valid UTC datetime parses - hour 14"); + + // Test adapt + Date date4 = DateUtil.adapt("2025-01-15"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.YEAR) == 2025, "StrictMode: valid adapt parses - year 2025"); + + } finally { + // Reset to default lenient mode + DateUtil.setStrictValidation(false); + } + ` + }, + { + name: 'DateUtilTest_LenientValidation_ReturnsMaxDate', + javaCode: ` + // Ensure lenient mode is enabled (default) + DateUtil.setStrictValidation(false); + + // Test 1: Default should be lenient (strictValidation = false) + test(DateUtil.getStrictValidation() == false, "Default has strictValidation=false"); + + // Test 2: Invalid format should return MAX_DATE, not throw + Date result1 = DateUtil.parseDateString("not-a-date"); + test(result1.equals(DateParser.MAX_DATE), "LenientMode: invalid format returns MAX_DATE"); + + // Test 3: Empty string should return MAX_DATE + Date result2 = DateUtil.parseDateString(""); + test(result2.equals(DateParser.MAX_DATE), "LenientMode: empty string returns MAX_DATE"); + + // Test 4: Null should return MAX_DATE + Date result3 = DateUtil.parseDateString(null); + test(result3.equals(DateParser.MAX_DATE), "LenientMode: null returns MAX_DATE"); + + // Test 5: parseDateTime with invalid returns MAX_DATE + Date result4 = DateUtil.parseDateTime("garbage"); + test(result4.equals(DateParser.MAX_DATE), "LenientMode parseDateTime: invalid returns MAX_DATE"); + + // Test 6: parseDateTimeUTC with invalid returns MAX_DATE + Date result5 = DateUtil.parseDateTimeUTC("invalid"); + test(result5.equals(DateParser.MAX_DATE), "LenientMode parseDateTimeUTC: invalid returns MAX_DATE"); + + // Test 7: adapt with invalid returns MAX_DATE + Date result6 = DateUtil.adapt("invalid date string"); + test(result6.equals(DateParser.MAX_DATE), "LenientMode adapt: invalid returns MAX_DATE"); + ` + }, + { + name: 'DateUtilTest_LenientValidation_ValidDatesWork', + javaCode: ` + // Ensure lenient mode is enabled (default) + DateUtil.setStrictValidation(false); + + // Test that valid dates work in lenient mode + Date date1 = DateUtil.parseDateString("2025-01-15"); + Calendar cal1 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal1.setTime(date1); + test(cal1.get(Calendar.YEAR) == 2025, "LenientMode: valid date parses - year 2025"); + test(cal1.get(Calendar.MONTH) == 0, "LenientMode: valid date parses - month Jan"); + test(cal1.get(Calendar.DAY_OF_MONTH) == 15, "LenientMode: valid date parses - day 15"); + + // Test parseDateTime + Date date2 = DateUtil.parseDateTime("2025-01-15T14:30:45"); + Calendar cal2 = Calendar.getInstance(); + cal2.setTime(date2); + test(cal2.get(Calendar.YEAR) == 2025, "LenientMode: valid datetime parses - year 2025"); + test(cal2.get(Calendar.HOUR_OF_DAY) == 14, "LenientMode: valid datetime parses - hour 14"); + + // Test parseDateTimeUTC + Date date3 = DateUtil.parseDateTimeUTC("2025-01-15T14:30:45Z"); + Calendar cal3 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal3.setTime(date3); + test(cal3.get(Calendar.YEAR) == 2025, "LenientMode: valid UTC datetime parses - year 2025"); + test(cal3.get(Calendar.HOUR_OF_DAY) == 14, "LenientMode: valid UTC datetime parses - hour 14"); + + // Test adapt + Date date4 = DateUtil.adapt("2025-01-15"); + Calendar cal4 = Calendar.getInstance(TimeZone.getTimeZone("GMT")); + cal4.setTime(date4); + test(cal4.get(Calendar.YEAR) == 2025, "LenientMode: valid adapt parses - year 2025"); + ` } ] }); diff --git a/src/pom.js b/src/pom.js index 4156ee5f02..8218deb648 100644 --- a/src/pom.js +++ b/src/pom.js @@ -430,6 +430,7 @@ foam.POM({ { name: "foam/parse/NumberGrammar", flags: "js" }, { name: "foam/parse/NumberParser", flags: "js" }, { name: "foam/parse/auto/SmartView", flags: "web" }, + { name: "foam/parse/SimpleQueryParser", flags: "js" }, { name: "foam/parse/FScriptParser", flags: "js" }, { name: "foam/parse/test/FScriptParserTestUser", flags: "js&test|java&test" }, { name: "foam/parse/test/FScriptParserTest", flags: "js&test|java&test" }, @@ -437,7 +438,8 @@ foam.POM({ { name: "foam/parse/test/QueryParserUserTest", flags: "js&test|java&test" }, { name: "foam/parse/test/SimpleQueryParserTest", flags: "js&test|java&test" }, { name: "foam/parse/test/DateParserTest", flags: "js&test|java&test" }, - { name: "foam/parse/test/NumberParserTest", flags: "js&test|java&test" }, + { name: "foam/parse/test/NumberParserTest", flags: "js&test|java&test" }, + { name: "foam/parse/test/DateParserJavaTest", flags: "js&test|java&test" }, { name: "foam/physics/Physical", flags: "js" }, { name: "foam/physics/Collider", flags: "js" }, { name: "foam/physics/PhysicsEngine", flags: "js" }, @@ -1217,6 +1219,7 @@ foam.POM({ { name: "foam/parse/FScriptParser" }, { name: "foam/parse/TestUser", flags: "test" }, { name: "foam/parse/QueryParser" }, + { name: "foam/parse/DateParser" }, { name: "foam/dao/OrderedDAO" }, { name: "foam/dao/HTTPSink" }, { name: "foam/dao/AuthorizationSink" },