DateTimeFunctionFactory.java
// © 2022 and later: Unicode, Inc. and others.
// License & terms of use: https://www.unicode.org/copyright.html
package com.ibm.icu.message2;
import com.ibm.icu.impl.JavaTimeConverters;
import com.ibm.icu.text.DateFormat;
import java.time.DayOfWeek;
import java.time.Month;
import java.time.temporal.Temporal;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Creates a {@link Function} doing formatting of date / time, similar to <code>{exp, date}</code>
* and <code>{exp, time}</code> in {@link com.ibm.icu.text.MessageFormat}.
*
* <p>It does not do selection.
*/
class DateTimeFunctionFactory implements FunctionFactory {
private final String kind;
// "datetime", "date", "time"
DateTimeFunctionFactory(String kind) {
switch (kind) {
case "date":
break;
case "time":
break;
case "datetime":
break;
default:
kind = "datetime";
}
this.kind = kind;
}
/**
* {@inheritDoc}
*
* @throws IllegalArgumentException when something goes wrong (for example conflicting options,
* invalid option values, etc.)
*/
@Override
public Function create(Locale locale, Map<String, Object> fixedOptions) {
locale = OptUtils.getBestLocale(fixedOptions, locale);
Directionality dir = OptUtils.getBestDirectionality(fixedOptions, locale);
boolean reportErrors = OptUtils.reportErrors(fixedOptions);
// TODO: how to handle conflicts. What if we have both skeleton and style, or pattern?
String skeleton = "";
switch (kind) {
case "date":
skeleton = getDateFieldOptions(fixedOptions, "fields", "length");
break;
case "time":
skeleton = getTimeFieldOptions(fixedOptions, "precision");
break;
case "datetime": // $FALL-THROUGH$
default:
skeleton = getDateFieldOptions(fixedOptions, "dateFields", "dateLength");
skeleton += getTimeFieldOptions(fixedOptions, "timePrecision");
break;
}
if (skeleton.isEmpty()) {
// Custom option, icu namespace
skeleton = OptUtils.getString(fixedOptions, "icu:skeleton", "");
}
if (skeleton.isEmpty()) {
// No MF2 standard options and no icu:skeleton, use defaults
skeleton = "";
// No skeletons, custom or otherwise, match fallback to short / short as per spec.
switch (kind) {
case "date": // {$d :date fields=year-month-day length=medium}
skeleton = DATE_STYLES_TO_SKELETON.get("year-month-day-weekday::medium");
break;
case "time": // {$t :time precision=minute}
skeleton = TIME_STYLES_TO_SKELETON.get("minute::");
break;
case "datetime": // $FALL-THROUGH$
default: // {$d :datetime dateFields=year-month-day timePrecision=minute}
skeleton =
DATE_STYLES_TO_SKELETON.get("year-month-day-weekday::medium")
+ TIME_STYLES_TO_SKELETON.get("minute::");
}
}
com.ibm.icu.util.TimeZone tz = com.ibm.icu.util.TimeZone.getDefault();
String timeZoneOverride = OptUtils.getString(fixedOptions, "timeZone", "");
if (!timeZoneOverride.isEmpty()) {
tz = com.ibm.icu.util.TimeZone.getTimeZone(timeZoneOverride);
}
String calendarOverride = OptUtils.getString(fixedOptions, "calendar", "");
if (!calendarOverride.isEmpty()) {
locale =
new Locale.Builder()
.setLocale(locale)
.setUnicodeLocaleKeyword("ca", calendarOverride)
.build();
}
DateFormat df = DateFormat.getInstanceForSkeleton(skeleton, locale);
if (!tz.equals(com.ibm.icu.util.TimeZone.UNKNOWN_ZONE)) {
df.setTimeZone(tz);
}
return new DateTimeFunctionImpl(locale, df, reportErrors);
}
private static Map<String, String> DATE_STYLES_TO_SKELETON =
Map.ofEntries(
// dateFields + dateLength
Map.entry("weekday::long", "EEEE"),
Map.entry("weekday::medium", "E"),
Map.entry("weekday::short", "EEEEEE"),
Map.entry("day-weekday::long", "dEEEE"),
Map.entry("day-weekday::medium", "dE"),
Map.entry("day-weekday::short", "dEEEEEE"),
Map.entry("month-day::long", "MMMMd"),
Map.entry("month-day::medium", "MMMd"),
Map.entry("month-day::short", "Md"),
Map.entry("month-day-weekday::long", "MMMMdEEEE"),
Map.entry("month-day-weekday::medium", "MMMdE"),
Map.entry("month-day-weekday::short", "MdEEEEEE"),
Map.entry("year-month-day::long", "yMMMMd"),
Map.entry("year-month-day::medium", "yMMMd"),
Map.entry("year-month-day::short", "yMd"),
Map.entry("year-month-day-weekday::long", "yMMMMdEEEE"),
Map.entry("year-month-day-weekday::medium", "yMMMdE"),
Map.entry("year-month-day-weekday::short", "yMdEEEEEE"));
private static Map<String, String> TIME_STYLES_TO_SKELETON =
Map.ofEntries(
// timePrecision + hour12
Map.entry("hour::", "j"),
Map.entry("hour::true", "h"),
Map.entry("hour::false", "H"),
Map.entry("minute::", "jm"),
Map.entry("minute::true", "hm"),
Map.entry("minute::false", "Hm"),
Map.entry("second::", "jms"),
Map.entry("second::true", "hms"),
Map.entry("second::false", "Hms"));
private static String getDateFieldOptions(
Map<String, Object> options, String fieldName, String lengthName) {
StringBuilder skeleton = new StringBuilder();
String opt;
// In all the switches below we just ignore invalid options.
// Would be nice to report (log?), but ICU does not have a clear policy on how to do that.
// But we don't want to throw, that is too drastic.
opt =
OptUtils.getString(options, fieldName, "")
+ "::"
+ OptUtils.getString(options, lengthName, "");
opt = DATE_STYLES_TO_SKELETON.get(opt);
if (opt != null) {
skeleton.append(opt);
}
return skeleton.toString();
}
private static String getTimeFieldOptions(Map<String, Object> options, String precisionName) {
StringBuilder skeleton = new StringBuilder();
String opt;
// In all the switches below we just ignore invalid options.
// Would be nice to report (log?), but ICU does not have a clear policy on how to do that.
// But we don't want to throw, that is too drastic.
opt =
OptUtils.getString(options, precisionName, "")
+ "::"
+ OptUtils.getString(options, "hour12", "");
opt = TIME_STYLES_TO_SKELETON.get(opt);
if (opt != null) {
skeleton.append(opt);
}
opt = OptUtils.getString(options, "timeZoneStyle", "");
switch (opt) {
case "long":
skeleton.append("zzzz");
break;
case "short":
skeleton.append("z");
break;
default:
break;
}
return skeleton.toString();
}
private static class DateTimeFunctionImpl implements Function {
private final DateFormat icuFormatter;
private final Locale locale;
private final boolean reportErrors;
private DateTimeFunctionImpl(Locale locale, DateFormat df, boolean reportErrors) {
this.locale = locale;
this.icuFormatter = df;
this.reportErrors = reportErrors;
}
/** {@inheritDoc} */
@Override
public FormattedPlaceholder format(Object toFormat, Map<String, Object> variableOptions) {
// TODO: use a special type to indicate function without input argument.
if (toFormat == null) {
return null;
}
if (toFormat instanceof CharSequence) {
toFormat = parseIso8601(toFormat.toString());
// We were unable to parse the input as iso date
if (toFormat instanceof CharSequence) {
if (reportErrors) {
throw new IllegalArgumentException(
"bad-operand: argument must be ISO 8601");
}
return new FormattedPlaceholder(
toFormat, new PlainStringFormattedValue("{|" + toFormat + "|}"));
}
} else if (toFormat instanceof Temporal) {
toFormat = JavaTimeConverters.temporalToCalendar((Temporal) toFormat);
} else if (toFormat instanceof DayOfWeek) {
toFormat = JavaTimeConverters.dayOfWeekToCalendar((DayOfWeek) toFormat);
} else if (toFormat instanceof Month) {
toFormat = JavaTimeConverters.monthToCalendar((Month) toFormat);
}
// Not an else-if here, because the `Temporal` conditions before make `toFormat` a
// `Calendar`
if (toFormat instanceof java.util.Calendar) {
toFormat = JavaTimeConverters.convertCalendar((java.util.Calendar) toFormat);
}
String result = icuFormatter.format(toFormat);
return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(result));
}
/** {@inheritDoc} */
@Override
public String formatToString(Object toFormat, Map<String, Object> variableOptions) {
FormattedPlaceholder result = format(toFormat, variableOptions);
return result != null ? result.toString() : null;
}
}
private static final Pattern ISO_PATTERN =
Pattern.compile(
"^(([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])){1}(T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(\\.[0-9]{1,3})?(Z|[+-]((0[0-9]|1[0-3]):[0-5][0-9]|14:00))?)?$");
private static Integer safeParse(String str) {
if (str == null || str.isEmpty()) {
return null;
}
return Integer.parseInt(str);
}
private static Object parseIso8601(String text) {
Matcher m = ISO_PATTERN.matcher(text);
if (m.find() && m.groupCount() == 12 && !m.group().isEmpty()) {
Integer year = safeParse(m.group(2));
Integer month = safeParse(m.group(3));
Integer day = safeParse(m.group(4));
Integer hour = safeParse(m.group(6));
Integer minute = safeParse(m.group(7));
Integer second = safeParse(m.group(8));
Integer millisecond = 0;
if (m.group(9) != null) {
String z = (m.group(9) + "000").substring(1, 4);
millisecond = safeParse(z);
} else {
millisecond = 0;
}
String tzPart = m.group(10);
if (hour == null) {
hour = 0;
minute = 0;
second = 0;
}
com.ibm.icu.util.GregorianCalendar gc =
new com.ibm.icu.util.GregorianCalendar(
year, month - 1, day, hour, minute, second);
gc.set(com.ibm.icu.util.Calendar.MILLISECOND, millisecond);
if (tzPart != null) {
if (tzPart.equals("Z")) {
gc.setTimeZone(com.ibm.icu.util.TimeZone.GMT_ZONE);
} else {
int sign = tzPart.startsWith("-") ? -1 : 1;
String[] tzParts = tzPart.substring(1).split(":");
if (tzParts.length == 2) {
Integer tzHour = safeParse(tzParts[0]);
Integer tzMin = safeParse(tzParts[1]);
if (tzHour != null && tzMin != null) {
int offset = sign * (tzHour * 60 + tzMin) * 60 * 1000;
gc.setTimeZone(new com.ibm.icu.util.SimpleTimeZone(offset, "offset"));
}
}
}
}
return gc;
}
return text;
}
}