/*
* eXist Open Source Native XML Database
* Copyright (C) 2012 The eXist Project
* http://exist-db.org
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*
* $Id$
*/
package org.exist.xquery.functions.fn;
import org.exist.dom.QName;
import org.exist.xquery.*;
import org.exist.xquery.util.NumberFormatter;
import org.exist.xquery.value.*;
import java.util.Calendar;
import java.util.Locale;
import java.util.Optional;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class FnFormatDates extends BasicFunction {
private final static String DEFAULT_LANGUAGE = Locale.getDefault().getLanguage();
private static FunctionParameterSequenceType DATETIME =
new FunctionParameterSequenceType(
"value", Type.DATE_TIME, Cardinality.ZERO_OR_ONE, "The datetime");
private static FunctionParameterSequenceType DATE =
new FunctionParameterSequenceType(
"value", Type.DATE, Cardinality.ZERO_OR_ONE, "The date");
private static FunctionParameterSequenceType TIME =
new FunctionParameterSequenceType(
"value", Type.TIME, Cardinality.ZERO_OR_ONE, "The time");
private static FunctionParameterSequenceType PICTURE =
new FunctionParameterSequenceType(
"picture", Type.STRING, Cardinality.EXACTLY_ONE, "The picture string");
private static FunctionParameterSequenceType LANGUAGE =
new FunctionParameterSequenceType(
"language", Type.STRING, Cardinality.ZERO_OR_ONE, "The language string");
private static FunctionParameterSequenceType CALENDAR =
new FunctionParameterSequenceType(
"calendar", Type.STRING, Cardinality.ZERO_OR_ONE, "The calendar string");
private static FunctionParameterSequenceType PLACE =
new FunctionParameterSequenceType(
"place", Type.STRING, Cardinality.ZERO_OR_ONE, "The place string");
private static FunctionReturnSequenceType RETURN =
new FunctionReturnSequenceType(
Type.STRING, Cardinality.EXACTLY_ONE, "The formatted date");
public final static FunctionSignature FNS_FORMAT_DATETIME_2 = new FunctionSignature(
new QName("format-dateTime", Function.BUILTIN_FUNCTION_NS),
"Returns a string containing an xs:date value formatted for display.",
new SequenceType[] {
DATETIME,
PICTURE
},
RETURN
);
public final static FunctionSignature FNS_FORMAT_DATETIME_5 = new FunctionSignature(
new QName("format-dateTime", Function.BUILTIN_FUNCTION_NS),
"Returns a string containing an xs:date value formatted for display.",
new SequenceType[] {
DATETIME,
PICTURE,
LANGUAGE,
CALENDAR,
PLACE
},
RETURN
);
public final static FunctionSignature FNS_FORMAT_DATE_2 = new FunctionSignature(
new QName("format-date", Function.BUILTIN_FUNCTION_NS),
"Returns a string containing an xs:date value formatted for display.",
new SequenceType[] {
DATE,
PICTURE
},
RETURN
);
public final static FunctionSignature FNS_FORMAT_DATE_5 = new FunctionSignature(
new QName("format-date", Function.BUILTIN_FUNCTION_NS),
"Returns a string containing an xs:date value formatted for display.",
new SequenceType[] {
DATE,
PICTURE,
LANGUAGE,
CALENDAR,
PLACE
},
RETURN
);
public final static FunctionSignature FNS_FORMAT_TIME_2 = new FunctionSignature(
new QName("format-time", Function.BUILTIN_FUNCTION_NS),
"Returns a string containing an xs:time value formatted for display.",
new SequenceType[] {
TIME,
PICTURE
},
RETURN
);
public final static FunctionSignature FNS_FORMAT_TIME_5 = new FunctionSignature(
new QName("format-time", Function.BUILTIN_FUNCTION_NS),
"Returns a string containing an xs:time value formatted for display.",
new SequenceType[] {
TIME,
PICTURE,
LANGUAGE,
CALENDAR,
PLACE
},
RETURN
);
private static Pattern componentPattern = Pattern.compile("([YMDdWwFHhmsfZzPCE])\\s*(.*)");
public FnFormatDates(XQueryContext context, FunctionSignature signature) {
super(context, signature);
}
@Override
public Sequence eval(Sequence[] args, Sequence contextSequence) throws XPathException {
if (args[0].isEmpty())
{return Sequence.EMPTY_SEQUENCE;}
final AbstractDateTimeValue value = (AbstractDateTimeValue) args[0].itemAt(0);
final String picture = args[1].getStringValue();
final Optional<String> language;
final Optional<String> place;
if (getArgumentCount() == 5) {
if (args[2].hasOne()) {
language = Optional.of(args[2].getStringValue());
} else {
language = Optional.empty();
}
if(args[4].hasOne()) {
place = Optional.of(args[4].getStringValue());
} else {
place = Optional.empty();
}
} else {
language = Optional.empty();
place = Optional.empty();
}
return new StringValue(formatDate(picture, value, language, place));
}
private String formatDate(String pic, AbstractDateTimeValue dt, final Optional<String> language,
final Optional<String> place) throws XPathException {
final boolean tzHMZNPictureHint = pic.equals("[H00]:[M00] [ZN]");
final StringBuilder sb = new StringBuilder();
int i = 0;
while (true) {
while (i < pic.length() && pic.charAt(i) != '[') {
sb.append(pic.charAt(i));
if (pic.charAt(i) == ']') {
i++;
if (i == pic.length() || pic.charAt(i) != ']') {
throw new XPathException(this, ErrorCodes.FOFD1340, "Closing ']' in date picture must be written as ']]'");
}
}
i++;
}
if (i == pic.length()) {
break;
}
// look for '[['
i++;
if (i < pic.length() && pic.charAt(i) == '[') {
sb.append('[');
i++;
} else {
final int close = (i < pic.length() ? pic.indexOf("]", i) : -1);
if (close == -1) {
throw new XPathException(this, ErrorCodes.FOFD1340, "Date format contains a '[' with no matching ']'");
}
final String component = pic.substring(i, close);
formatComponent(component, dt, language, place, tzHMZNPictureHint, sb);
i = close + 1;
}
}
return sb.toString();
}
private void formatComponent(String component, AbstractDateTimeValue dt, final Optional<String> language,
final Optional<String> place, final boolean tzHMZNPictureHint, final StringBuilder sb)
throws XPathException {
final Matcher matcher = componentPattern.matcher(component);
if (!matcher.matches()) {
throw new XPathException(this, ErrorCodes.FOFD1340, "Unrecognized date/time component: " + component);
}
final char specifier = component.charAt(0);
String width = null;
String picture = matcher.group(2);
// check if there's an optional width specifier
final int widthSep = picture.indexOf(',');
if (-1 < widthSep) {
width = picture.substring(widthSep + 1);
picture = picture.substring(0, widthSep);
}
// get default format picture if none was specified
if (picture == null || picture.length() == 0) {
picture = getDefaultFormat(specifier);
}
final boolean allowDate = !Type.subTypeOf(dt.getType(), Type.TIME);
final boolean allowTime = !Type.subTypeOf(dt.getType(), Type.DATE);
switch (specifier) {
case 'Y':
if (allowDate) {
final int year = dt.getPart(AbstractDateTimeValue.YEAR);
formatNumber(specifier, picture, width, year, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a year component");
}
break;
case 'M':
if(!tzHMZNPictureHint) {
if (allowDate) {
final int month = dt.getPart(AbstractDateTimeValue.MONTH);
formatNumber(specifier, picture, width, month, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a month component");
}
} else {
if (allowTime) {
final int minute = dt.getPart(AbstractDateTimeValue.MINUTE);
formatNumber(specifier, picture, width, minute, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-date does not support a minute component");
}
}
break;
case 'D':
if (allowDate) {
final int day = dt.getPart(AbstractDateTimeValue.DAY);
formatNumber(specifier, picture, width, day, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a day component");
}
break;
case 'd':
if (allowDate) {
final int dayInYear = dt.getDayWithinYear();
formatNumber(specifier, picture, width, dayInYear, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a day component");
}
break;
case 'W':
if (allowDate) {
final int week = dt.getWeekWithinYear();
formatNumber(specifier, picture, width, week, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a week component");
}
break;
case 'w':
if (allowDate) {
final int week = dt.getWeekWithinMonth();
formatNumber(specifier, picture, width, week, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a week component");
}
break;
case 'F':
if (allowDate) {
final int day = dt.getDayOfWeek();
formatNumber(specifier, picture, width, day, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-time does not support a day component");
}
break;
case 'H':
if (allowTime) {
final int hour = dt.getPart(AbstractDateTimeValue.HOUR);
formatNumber(specifier, picture, width, hour, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-date does not support a hour component");
}
break;
case 'h':
if (allowTime) {
int hour = dt.getPart(AbstractDateTimeValue.HOUR) % 12;
if (hour == 0)
{hour = 12;}
formatNumber(specifier, picture, width, hour, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-date does not support a hour component");
}
break;
case 'm':
if (allowTime) {
final int minute = dt.getPart(AbstractDateTimeValue.MINUTE);
formatNumber(specifier, picture, width, minute, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-date does not support a minute component");
}
break;
case 's':
if (allowTime) {
final int second = dt.getPart(AbstractDateTimeValue.SECOND);
formatNumber(specifier, picture, width, second, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350, "format-date does not support a second component");
}
break;
case 'f':
if (allowTime) {
final int fraction = dt.getPart(AbstractDateTimeValue.MILLISECOND);
formatNumber(specifier, picture, width, fraction, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350,
"format-date does not support a fractional seconds component");
}
break;
case 'P':
if (allowTime) {
final int hour = dt.getPart(AbstractDateTimeValue.HOUR);
formatNumber(specifier, picture, width, hour, language, sb);
} else {
throw new XPathException(this, ErrorCodes.FOFD1350,
"format-date does not support an am/pm component");
}
break;
case 'z':
if(dt.getTimezone() != Sequence.EMPTY_SEQUENCE) {
sb.append("GMT");
}
case 'Z':
final Calendar cal = dt.toJavaObject(Calendar.class);
final Sequence tz = dt.getTimezone();
if(tz != Sequence.EMPTY_SEQUENCE) {
final DayTimeDurationValue dtv = ((DayTimeDurationValue)tz);
//cope with eXist's duration class's weird #getPart method
int minute = dtv.getPart(DurationValue.MINUTE);
if(minute < 0) {
minute = minute * -1;
}
sb.append(formatTimeZone(picture,
dtv.getPart(DurationValue.HOUR), minute, cal.getTimeZone(), language, place));
}
break;
default:
throw new XPathException(this, ErrorCodes.FOFD1340, "Unrecognized date/time component: " + component);
}
}
private String formatTimeZone(final String timezonePicture, final int hour, final int minute,
final TimeZone timeZone, final Optional<String> language, final Optional<String> place) {
final Locale locale = new Locale(language.orElse(DEFAULT_LANGUAGE));
final String format;
switch(timezonePicture) {
case "0":
if(minute != 0) {
format = "%+d:%02d";
} else {
format = "%+d";
}
break;
case "0000":
format = "%+03d%02d";
break;
case "0:00":
format = "%+d:%02d";
break;
case "00:00t":
if(hour == 0 && minute == 0) {
format = "Z";
} else {
format = "%+03d:%02d";
}
break;
case "N":
final TimeZone tz = place.map(TimeZone::getTimeZone).orElse(timeZone);
return tz.getDisplayName(timeZone.useDaylightTime(), TimeZone.SHORT, locale);
case "Z":
return formatMilitaryTimeZone(hour, minute);
case "00:00":
default:
format = "%+03d:%02d";
}
return String.format(locale, format, hour, minute);
}
private final static char[] MILITARY_TZ_CHARS = {'Z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y' };
/**
* Military time zone
*
* Z = +00:00, A = +01:00, B = +02:00, ..., M = +12:00, N = -01:00, O = -02:00, ... Y = -12:00.
*
* The letter J (meaning local time) is used in the case of a value that does not specify a timezone
* offset.
*
* Timezone offsets that have no representation in this system (for example Indian Standard Time, +05:30)
* are output as if the format 01:01 had been requested.
*/
private String formatMilitaryTimeZone(final int hour, final int minute) {
if(minute == 0 && hour > -12 && hour < 12) {
final int offset;
if(hour < 0) {
offset = 13 + (hour * -1);
} else {
offset = hour;
}
return String.valueOf(MILITARY_TZ_CHARS[offset]);
} else {
return String.format("%+03d:%02d", hour, minute);
}
}
private String getDefaultFormat(char specifier) {
switch (specifier) {
case 'F':
return "Nn";
case 'P':
return "n";
case 'C':
case 'E':
return "N";
case 'm':
case 's':
return "01";
case 'z':
case 'Z':
return "00:00";
default:
return "1";
}
}
private void formatNumber(char specifier, String picture, String width, int num, final Optional<String> language,
StringBuilder sb) throws XPathException {
final NumberFormatter formatter = NumberFormatter.getInstance(language.orElse(DEFAULT_LANGUAGE));
if ("N".equals(picture) || "n".equals(picture) || "Nn".equals(picture)) {
String name;
switch (specifier) {
case 'M':
name = formatter.getMonth(num);
break;
case 'F':
name = formatter.getDay(num);
break;
case 'P':
name = formatter.getAmPm(num);
break;
default:
name = "";
break;
}
if ("N".equals(picture)) {
name = name.toUpperCase();
} else if ("n".equals(picture)) {
name = name.toLowerCase();
}
final int widths[] = getWidths(width);
if (widths != null) {
final int min = widths[0];
final int max = widths[1];
final StringBuilder ws = new StringBuilder();
while(name.length() < min) {
ws.append(" ");
}
name = name + ws.toString();
if(name.length() > max) {
name = name.substring(0, max);
}
}
sb.append(name);
return;
}
// determine min and max width
int min = NumberFormatter.getMinDigits(picture);
int max = NumberFormatter.getMaxDigits(picture);
if (max == 1) {
max = Integer.MAX_VALUE;
}
// explicit width takes precedence
final int widths[] = getWidths(width);
if (widths != null) {
if (widths[0] > 0) {min = widths[0];}
if (widths[1] > 0) {max = widths[1];}
}
try {
sb.append(formatter.formatNumber(num, picture, min, max));
} catch (final XPathException e) {
throw new XPathException(this, ErrorCodes.FOFD1350, e.getMessage());
}
}
private int[] getWidths(String width) throws XPathException {
if (width == null || width.length() == 0)
{return null;}
int min = -1;
int max = -1;
String minPart = width;
String maxPart = null;
final int p = width.indexOf('-');
if (p < 0) {
minPart = width;
} else {
minPart = width.substring(0, p);
maxPart = width.substring(p + 1);
}
if ("*".equals(minPart))
{min = 1;}
else {
try {
min = Integer.parseInt(minPart);
} catch (final NumberFormatException e) {
}
}
if (maxPart != null) {
if ("*".equals(maxPart))
{max = Integer.MAX_VALUE;}
else {
try {
max = Integer.parseInt(maxPart);
} catch (final NumberFormatException e) {
}
}
}
if (max != -1 && min > max)
{throw new XPathException(this, ErrorCodes.FOFD1350,"Minimum width > maximum width in component");}
return new int[] { min, max };
}
}