diff --git a/src/main/java/com/cedarsoftware/util/DateUtilities.java b/src/main/java/com/cedarsoftware/util/DateUtilities.java
index 95ad30f5..537b2bdf 100644
--- a/src/main/java/com/cedarsoftware/util/DateUtilities.java
+++ b/src/main/java/com/cedarsoftware/util/DateUtilities.java
@@ -366,6 +366,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
}
Convention.throwIfNull(defaultZoneId, "ZoneId cannot be null. Use ZoneId.of(\"America/New_York\"), ZoneId.systemDefault(), etc.");
+ // If purely digits => epoch millis
if (allDigits.matcher(dateStr).matches()) {
return Instant.ofEpochMilli(Long.parseLong(dateStr)).atZone(defaultZoneId);
}
@@ -373,7 +374,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
String year, day, remains, tz = null;
int month;
- // Determine which date pattern to use
+ // 1) Try matching ISO or numeric style date
Matcher matcher = isoDatePattern.matcher(dateStr);
String remnant = matcher.replaceFirst("");
if (remnant.length() < dateStr.length()) {
@@ -388,6 +389,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
}
remains = remnant;
} else {
+ // 2) Try alphaMonthPattern
matcher = alphaMonthPattern.matcher(dateStr);
remnant = matcher.replaceFirst("");
if (remnant.length() < dateStr.length()) {
@@ -410,6 +412,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
}
month = months.get(mon.trim().toLowerCase());
} else {
+ // 3) Try unixDateTimePattern
matcher = unixDateTimePattern.matcher(dateStr);
if (matcher.replaceFirst("").length() == dateStr.length()) {
throw new IllegalArgumentException("Unable to parse: " + dateStr + " as a date-time");
@@ -418,20 +421,24 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
String mon = matcher.group(2);
month = months.get(mon.trim().toLowerCase());
day = matcher.group(3);
+
+ // e.g. "EST"
tz = matcher.group(5);
- remains = matcher.group(4); // leave optional time portion remaining
+
+ // time portion remains to parse
+ remains = matcher.group(4);
}
}
- // For the remaining String, match the time portion (which could have appeared ahead of the date portion)
+ // 4) Parse time portion (could appear before or after date)
String hour = null, min = null, sec = "00", fracSec = "0";
remains = remains.trim();
matcher = timePattern.matcher(remains);
remnant = matcher.replaceFirst("");
-
+
if (remnant.length() < remains.length()) {
hour = matcher.group(1);
- min = matcher.group(2);
+ min = matcher.group(2);
if (matcher.group(3) != null) {
sec = matcher.group(3);
}
@@ -442,20 +449,29 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
tz = matcher.group(5).trim();
}
if (matcher.group(6) != null) {
- // to make round trip of ZonedDateTime equivalent we need to use the original Zone as ZoneId
- // ZoneId is a much broader definition handling multiple possible dates, and we want this to
- // be equivalent to the original zone that was used if one was present.
tz = stripBrackets(matcher.group(6).trim());
}
}
+ // 5) If strict, verify no leftover text
if (ensureDateTimeAlone) {
verifyNoGarbageLeft(remnant);
}
- ZoneId zoneId = StringUtilities.isEmpty(tz) ? defaultZoneId : getTimeZone(tz);
- ZonedDateTime dateTime = getDate(dateStr, zoneId, year, month, day, hour, min, sec, fracSec);
- return dateTime;
+ ZoneId zoneId;
+ try {
+ zoneId = StringUtilities.isEmpty(tz) ? defaultZoneId : getTimeZone(tz);
+ } catch (Exception e) {
+ if (ensureDateTimeAlone) {
+ // In strict mode, rethrow
+ throw e;
+ }
+ // else in non-strict mode, ignore the invalid zone and default
+ zoneId = defaultZoneId;
+ }
+
+ // 6) Build the ZonedDateTime
+ return getDate(dateStr, zoneId, year, month, day, hour, min, sec, fracSec);
}
private static ZonedDateTime getDate(String dateStr,
diff --git a/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java b/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java
index bfbbc8af..2df61e39 100644
--- a/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java
+++ b/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java
@@ -10,13 +10,13 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import java.util.Date;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
-import com.cedarsoftware.util.CompactMap;
-
/**
* @author Kenny Partlow (kpartlow@gmail.com)
*
@@ -122,11 +122,22 @@ static OffsetDateTime toOffsetDateTime(Object from, Converter converter) {
static Map toMap(Object from, Converter converter) {
Calendar cal = (Calendar) from;
- Map target = CompactMap.builder().insertionOrder().build();
- target.put(MapConversions.DATE, LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)).toString());
- target.put(MapConversions.TIME, LocalTime.of(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND), cal.get(Calendar.MILLISECOND) * 1_000_000).toString());
- target.put(MapConversions.ZONE, cal.getTimeZone().toZoneId().toString());
- target.put(MapConversions.EPOCH_MILLIS, cal.getTimeInMillis());
+ ZonedDateTime zdt = cal.toInstant().atZone(cal.getTimeZone().toZoneId());
+
+ // Format with timezone keeping DST information
+ String formatted;
+ int ms = zdt.getNano() / 1_000_000; // Convert nanos to millis
+ if (ms == 0) {
+ // No fractional seconds
+ formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'['VV']'"));
+ } else {
+ // Millisecond precision
+ formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
+ + String.format(".%03d[%s]", ms, zdt.getZone());
+ }
+
+ Map target = new LinkedHashMap<>();
+ target.put(MapConversions.CALENDAR, formatted);
return target;
}
}
diff --git a/src/main/java/com/cedarsoftware/util/convert/MapConversions.java b/src/main/java/com/cedarsoftware/util/convert/MapConversions.java
index 14b68d2e..06e23178 100644
--- a/src/main/java/com/cedarsoftware/util/convert/MapConversions.java
+++ b/src/main/java/com/cedarsoftware/util/convert/MapConversions.java
@@ -64,6 +64,7 @@ final class MapConversions {
static final String VALUE = "value";
static final String DATE = "date";
static final String SQL_DATE = "sqlDate";
+ static final String CALENDAR = "calendar";
static final String TIME = "time";
static final String TIMESTAMP = "timestamp";
static final String ZONE = "zone";
@@ -292,47 +293,18 @@ static TimeZone toTimeZone(Object from, Converter converter) {
static Calendar toCalendar(Object from, Converter converter) {
Map map = (Map) from;
- Object epochMillis = map.get(EPOCH_MILLIS);
- if (epochMillis != null) {
- return converter.convert(epochMillis, Calendar.class);
- }
-
- Object date = map.get(DATE);
- Object time = map.get(TIME);
- Object zone = map.get(ZONE); // optional
- ZoneId zoneId;
- if (zone != null) {
- zoneId = converter.convert(zone, ZoneId.class);
- } else {
- zoneId = converter.getOptions().getZoneId();
- }
-
- if (date != null && time != null) {
- LocalDate localDate = converter.convert(date, LocalDate.class);
- LocalTime localTime = converter.convert(time, LocalTime.class);
- LocalDateTime ldt = LocalDateTime.of(localDate, localTime);
- ZonedDateTime zdt = ldt.atZone(zoneId);
- Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zoneId));
- cal.set(Calendar.YEAR, zdt.getYear());
- cal.set(Calendar.MONTH, zdt.getMonthValue() - 1);
- cal.set(Calendar.DAY_OF_MONTH, zdt.getDayOfMonth());
- cal.set(Calendar.HOUR_OF_DAY, zdt.getHour());
- cal.set(Calendar.MINUTE, zdt.getMinute());
- cal.set(Calendar.SECOND, zdt.getSecond());
- cal.set(Calendar.MILLISECOND, zdt.getNano() / 1_000_000);
- cal.getTime();
- return cal;
- }
-
- if (time != null && date == null) {
- Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zoneId));
- ZonedDateTime zdt = DateUtilities.parseDate((String)time, zoneId, true);
+ Object calStr = map.get(CALENDAR);
+ if (calStr instanceof String && StringUtilities.hasContent((String)calStr)) {
+ ZonedDateTime zdt = DateUtilities.parseDate((String)calStr, converter.getOptions().getZoneId(), true);
+ Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zdt.getZone()));
cal.setTimeInMillis(zdt.toInstant().toEpochMilli());
return cal;
}
- return fromMap(from, converter, Calendar.class, new String[]{EPOCH_MILLIS}, new String[]{TIME, ZONE + OPTIONAL}, new String[]{DATE, TIME, ZONE + OPTIONAL});
- }
+ // Handle legacy/alternate formats via fromMap
+ return fromMap(from, converter, Calendar.class, new String[]{CALENDAR});
+ }
+
static Locale toLocale(Object from, Converter converter) {
Map map = (Map) from;
diff --git a/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java b/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java
index f95a6632..b8cd5ac0 100644
--- a/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java
+++ b/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java
@@ -115,7 +115,6 @@ static String toString(Object from, Converter converter) {
static Map toMap(Object from, Converter converter) {
Timestamp timestamp = (Timestamp) from;
- long millis = timestamp.getTime();
// 1) Convert Timestamp -> Instant -> UTC ZonedDateTime
ZonedDateTime zdt = timestamp.toInstant().atZone(ZoneOffset.UTC);
diff --git a/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java b/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java
index 2cc839f9..a8ed57bd 100644
--- a/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java
+++ b/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java
@@ -24,6 +24,7 @@
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -783,6 +784,78 @@ void testEpochMillis()
assertEquals("31690708-07-05 01:46:39.999", gmtDateString);
}
+ @Test
+ void testParseInvalidTimeZoneFormats() {
+ // Test with named timezone without time - should fail
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05[Asia/Tokyo]", ZoneId.of("Z"), false),
+ "Should fail with timezone but no time");
+
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05[Asia/Tokyo]", ZoneId.of("Z"), true),
+ "Should fail with timezone but no time");
+
+ // Test with offset without time - should fail
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05+09:00", ZoneId.of("Z"), false),
+ "Should fail with offset but no time");
+
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05+09:00", ZoneId.of("Z"), true),
+ "Should fail with offset but no time");
+
+ // Test with Z without time - should fail
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05Z", ZoneId.of("Z"), false),
+ "Should fail with Z but no time");
+
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05Z", ZoneId.of("Z"), true),
+ "Should fail with Z but no time");
+
+ // Test with T but no time - should fail
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05T[Asia/Tokyo]", ZoneId.of("Z"), false),
+ "Should fail with T but no time");
+
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05T[Asia/Tokyo]", ZoneId.of("Z"), true),
+ "Should fail with T but no time");
+ }
+
+ @Test
+ void testParseWithTrailingText() {
+ // Test with trailing text - should pass with strict=false
+ ZonedDateTime zdt = DateUtilities.parseDate("2024-02-05 is a great day", ZoneId.of("Z"), false);
+ assertEquals(2024, zdt.getYear());
+ assertEquals(2, zdt.getMonthValue());
+ assertEquals(5, zdt.getDayOfMonth());
+ assertEquals(ZoneId.of("Z"), zdt.getZone());
+ assertEquals(0, zdt.getHour());
+ assertEquals(0, zdt.getMinute());
+ assertEquals(0, zdt.getSecond());
+
+ // Test with trailing text - should fail with strict=true
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05 is a great day", ZoneId.of("Z"), true),
+ "Should fail with trailing text in strict mode");
+
+ // Test with trailing text after full datetime - should pass with strict=false
+ zdt = DateUtilities.parseDate("2024-02-05T10:30:45Z and then some text", ZoneId.of("Z"), false);
+ assertEquals(2024, zdt.getYear());
+ assertEquals(2, zdt.getMonthValue());
+ assertEquals(5, zdt.getDayOfMonth());
+ assertEquals(10, zdt.getHour());
+ assertEquals(30, zdt.getMinute());
+ assertEquals(45, zdt.getSecond());
+ assertEquals(ZoneId.of("Z"), zdt.getZone());
+
+ // Test with trailing text after full datetime - should fail with strict=true
+ assertThrows(IllegalArgumentException.class, () ->
+ DateUtilities.parseDate("2024-02-05T10:30:45Z and then some text", ZoneId.of("Z"), true),
+ "Should fail with trailing text in strict mode");
+ }
+
private static Stream provideTimeZones()
{
return Stream.of(
diff --git a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java
index 269b2b9a..9ff42c06 100644
--- a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java
+++ b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java
@@ -53,7 +53,6 @@
import com.cedarsoftware.io.WriteOptions;
import com.cedarsoftware.io.WriteOptionsBuilder;
import com.cedarsoftware.util.ClassUtilities;
-import com.cedarsoftware.util.CompactMap;
import com.cedarsoftware.util.DeepEquals;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
@@ -64,6 +63,7 @@
import static com.cedarsoftware.util.MapUtilities.mapOf;
import static com.cedarsoftware.util.convert.Converter.VALUE;
+import static com.cedarsoftware.util.convert.MapConversions.CALENDAR;
import static com.cedarsoftware.util.convert.MapConversions.CAUSE;
import static com.cedarsoftware.util.convert.MapConversions.CAUSE_MESSAGE;
import static com.cedarsoftware.util.convert.MapConversions.CLASS;
@@ -1913,68 +1913,52 @@ private static void loadCalendarTests() {
{new BigDecimal(1), cal(1000), true},
});
TEST_DB.put(pair(Map.class, Calendar.class), new Object[][]{
- {mapOf(VALUE, "2024-02-05T22:31:17.409[" + TOKYO + "]"), (Supplier) () -> {
+ // Test with timezone name format
+ {mapOf(CALENDAR, "2024-02-05T22:31:17.409[Asia/Tokyo]"), (Supplier) () -> {
Calendar cal = Calendar.getInstance(TOKYO_TZ);
cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17);
cal.set(Calendar.MILLISECOND, 409);
return cal;
- }},
- {mapOf(VALUE, "2024-02-05T22:31:17.409" + TOKYO_ZO.toString()), (Supplier) () -> {
+ }, true},
+
+ // Test with offset format
+ {mapOf(CALENDAR, "2024-02-05T22:31:17.409+09:00"), (Supplier) () -> {
Calendar cal = Calendar.getInstance(TOKYO_TZ);
cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17);
cal.set(Calendar.MILLISECOND, 409);
return cal;
- }},
- {(Supplier