001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools.date;
003
004import java.text.DateFormat;
005import java.text.ParsePosition;
006import java.text.SimpleDateFormat;
007import java.util.Calendar;
008import java.util.Date;
009import java.util.GregorianCalendar;
010import java.util.Locale;
011import java.util.TimeZone;
012
013import javax.xml.datatype.DatatypeConfigurationException;
014import javax.xml.datatype.DatatypeFactory;
015import javax.xml.datatype.XMLGregorianCalendar;
016
017import org.openstreetmap.josm.Main;
018import org.openstreetmap.josm.data.preferences.BooleanProperty;
019import org.openstreetmap.josm.tools.CheckParameterUtil;
020import org.openstreetmap.josm.tools.UncheckedParseException;
021
022/**
023 * A static utility class dealing with:
024 * <ul>
025 * <li>parsing XML date quickly and formatting a date to the XML UTC format regardless of current locale</li>
026 * <li>providing a single entry point for formatting dates to be displayed in JOSM GUI, based on user preferences</li>
027 * </ul>
028 * @author nenik
029 */
030public final class DateUtils {
031
032    protected DateUtils() {
033        // Hide default constructor for utils classes
034    }
035
036    /**
037     * Property to enable display of ISO dates globally.
038     * @since 7299
039     */
040    public static final BooleanProperty PROP_ISO_DATES = new BooleanProperty("iso.dates", false);
041
042    /**
043     * A shared instance used for conversion between individual date fields
044     * and long millis time. It is guarded against conflict by the class lock.
045     * The shared instance is used because the construction, together
046     * with the timezone lookup, is very expensive.
047     */
048    private static final GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
049    private static final GregorianCalendar calendarLocale = new GregorianCalendar(TimeZone.getDefault());
050    private static final DatatypeFactory XML_DATE;
051
052    static {
053        calendar.setTimeInMillis(0);
054        calendarLocale.setTimeInMillis(0);
055
056        DatatypeFactory fact = null;
057        try {
058            fact = DatatypeFactory.newInstance();
059        } catch (DatatypeConfigurationException ce) {
060            Main.error(ce);
061        }
062        XML_DATE = fact;
063    }
064
065    /**
066     * Parses XML date quickly, regardless of current locale.
067     * @param str The XML date as string
068     * @return The date
069     * @throws UncheckedParseException if the date does not match any of the supported date formats
070     */
071    public static synchronized Date fromString(String str) throws UncheckedParseException {
072        return new Date(tsFromString(str));
073    }
074
075    /**
076     * Parses XML date quickly, regardless of current locale.
077     * @param str The XML date as string
078     * @return The date in milliseconds since epoch
079     * @throws UncheckedParseException if the date does not match any of the supported date formats
080     */
081    public static synchronized long tsFromString(String str) throws UncheckedParseException {
082        // "2007-07-25T09:26:24{Z|{+|-}01[:00]}"
083        if (checkLayout(str, "xxxx-xx-xxTxx:xx:xxZ") ||
084                checkLayout(str, "xxxx-xx-xxTxx:xx:xx") ||
085                checkLayout(str, "xxxx:xx:xx xx:xx:xx") ||
086                checkLayout(str, "xxxx-xx-xx xx:xx:xx UTC") ||
087                checkLayout(str, "xxxx-xx-xxTxx:xx:xx+xx") ||
088                checkLayout(str, "xxxx-xx-xxTxx:xx:xx-xx") ||
089                checkLayout(str, "xxxx-xx-xxTxx:xx:xx+xx:00") ||
090                checkLayout(str, "xxxx-xx-xxTxx:xx:xx-xx:00")) {
091            final Calendar c = checkLayout(str, "xxxx:xx:xx xx:xx:xx") ? calendarLocale : calendar; // consider EXIF date in default timezone
092            c.set(
093                parsePart4(str, 0),
094                parsePart2(str, 5)-1,
095                parsePart2(str, 8),
096                parsePart2(str, 11),
097                parsePart2(str, 14),
098                parsePart2(str, 17));
099            c.set(Calendar.MILLISECOND, 0);
100
101            if (str.length() == 22 || str.length() == 25) {
102                int plusHr = parsePart2(str, 20);
103                int mul = str.charAt(19) == '+' ? -3600000 : 3600000;
104                return c.getTimeInMillis()+plusHr*mul;
105            }
106
107            return c.getTimeInMillis();
108        } else if (checkLayout(str, "xxxx-xx-xxTxx:xx:xx.xxxZ") ||
109                checkLayout(str, "xxxx-xx-xxTxx:xx:xx.xxx") ||
110                checkLayout(str, "xxxx:xx:xx xx:xx:xx.xxx") ||
111                checkLayout(str, "xxxx-xx-xxTxx:xx:xx.xxx+xx:00") ||
112                checkLayout(str, "xxxx-xx-xxTxx:xx:xx.xxx-xx:00")) {
113            final Calendar c = checkLayout(str, "xxxx:xx:xx xx:xx:xx.xxx") ? calendarLocale : calendar; // consider EXIF date in default timezone
114            c.set(
115                parsePart4(str, 0),
116                parsePart2(str, 5)-1,
117                parsePart2(str, 8),
118                parsePart2(str, 11),
119                parsePart2(str, 14),
120                parsePart2(str, 17));
121            c.set(Calendar.MILLISECOND, 0);
122            long millis = parsePart3(str, 20);
123            if (str.length() == 29) {
124                millis += parsePart2(str, 24) * (str.charAt(23) == '+' ? -3600000 : 3600000);
125            }
126
127            return c.getTimeInMillis() + millis;
128        } else {
129            // example date format "18-AUG-08 13:33:03"
130            SimpleDateFormat f = new SimpleDateFormat("dd-MMM-yy HH:mm:ss");
131            Date d = f.parse(str, new ParsePosition(0));
132            if (d != null)
133                return d.getTime();
134        }
135
136        try {
137            return XML_DATE.newXMLGregorianCalendar(str).toGregorianCalendar().getTimeInMillis();
138        } catch (IllegalArgumentException ex) {
139            throw new UncheckedParseException("The date string (" + str + ") could not be parsed.", ex);
140        }
141    }
142
143    private static String toXmlFormat(GregorianCalendar cal) {
144        XMLGregorianCalendar xgc = XML_DATE.newXMLGregorianCalendar(cal);
145        if (cal.get(Calendar.MILLISECOND) == 0) {
146            xgc.setFractionalSecond(null);
147        }
148        return xgc.toXMLFormat();
149    }
150
151    /**
152     * Formats a date to the XML UTC format regardless of current locale.
153     * @param timestamp number of seconds since the epoch
154     * @return The formatted date
155     */
156    public static synchronized String fromTimestamp(int timestamp) {
157        calendar.setTimeInMillis(timestamp * 1000L);
158        return toXmlFormat(calendar);
159    }
160
161    /**
162     * Formats a date to the XML UTC format regardless of current locale.
163     * @param date The date to format
164     * @return The formatted date
165     */
166    public static synchronized String fromDate(Date date) {
167        calendar.setTime(date);
168        return toXmlFormat(calendar);
169    }
170
171    private static boolean checkLayout(String text, String pattern) {
172        if (text.length() != pattern.length())
173            return false;
174        for (int i = 0; i < pattern.length(); i++) {
175            char pc = pattern.charAt(i);
176            char tc = text.charAt(i);
177            if (pc == 'x' && Character.isDigit(tc))
178                continue;
179            else if (pc == 'x' || pc != tc)
180                return false;
181        }
182        return true;
183    }
184
185    private static int num(char c) {
186        return c - '0';
187    }
188
189    private static int parsePart2(String str, int off) {
190        return 10 * num(str.charAt(off)) + num(str.charAt(off + 1));
191    }
192
193    private static int parsePart3(String str, int off) {
194        return 100 * num(str.charAt(off)) + 10 * num(str.charAt(off + 1)) + num(str.charAt(off + 2));
195    }
196
197    private static int parsePart4(String str, int off) {
198        return 1000 * num(str.charAt(off)) + 100 * num(str.charAt(off + 1)) + 10 * num(str.charAt(off + 2)) + num(str.charAt(off + 3));
199    }
200
201    /**
202     * Returns a new {@code SimpleDateFormat} for date only, according to <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>.
203     * @return a new ISO 8601 date format, for date only.
204     * @since 7299
205     */
206    public static SimpleDateFormat newIsoDateFormat() {
207        return new SimpleDateFormat("yyyy-MM-dd");
208    }
209
210    /**
211     * Returns a new {@code SimpleDateFormat} for date and time, according to <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>.
212     * @return a new ISO 8601 date format, for date and time.
213     * @since 7299
214     */
215    public static SimpleDateFormat newIsoDateTimeFormat() {
216        return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX");
217    }
218
219    /**
220     * Returns a new {@code SimpleDateFormat} for date and time, according to format used in OSM API errors.
221     * @return a new date format, for date and time, to use for OSM API error handling.
222     * @since 7299
223     */
224    public static SimpleDateFormat newOsmApiDateTimeFormat() {
225        // Example: "2010-09-07 14:39:41 UTC".
226        // Always parsed with US locale regardless of the current locale in JOSM
227        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US);
228    }
229
230    /**
231     * Returns the date format to be used for current user, based on user preferences.
232     * @param dateStyle The date style as described in {@link DateFormat#getDateInstance}. Ignored if "ISO dates" option is set
233     * @return The date format
234     * @since 7299
235     */
236    public static DateFormat getDateFormat(int dateStyle) {
237        if (PROP_ISO_DATES.get()) {
238            return newIsoDateFormat();
239        } else {
240            return DateFormat.getDateInstance(dateStyle, Locale.getDefault());
241        }
242    }
243
244    /**
245     * Formats a date to be displayed to current user, based on user preferences.
246     * @param date The date to display. Must not be {@code null}
247     * @param dateStyle The date style as described in {@link DateFormat#getDateInstance}. Ignored if "ISO dates" option is set
248     * @return The formatted date
249     * @since 7299
250     */
251    public static String formatDate(Date date, int dateStyle) {
252        CheckParameterUtil.ensureParameterNotNull(date, "date");
253        return getDateFormat(dateStyle).format(date);
254    }
255
256    /**
257     * Returns the time format to be used for current user, based on user preferences.
258     * @param timeStyle The time style as described in {@link DateFormat#getTimeInstance}. Ignored if "ISO dates" option is set
259     * @return The time format
260     * @since 7299
261     */
262    public static DateFormat getTimeFormat(int timeStyle) {
263        if (PROP_ISO_DATES.get()) {
264            // This is not strictly conform to ISO 8601. We just want to avoid US-style times such as 3.30pm
265            return new SimpleDateFormat("HH:mm:ss");
266        } else {
267            return DateFormat.getTimeInstance(timeStyle, Locale.getDefault());
268        }
269    }
270
271    /**
272     * Formats a time to be displayed to current user, based on user preferences.
273     * @param time The time to display. Must not be {@code null}
274     * @param timeStyle The time style as described in {@link DateFormat#getTimeInstance}. Ignored if "ISO dates" option is set
275     * @return The formatted time
276     * @since 7299
277     */
278    public static String formatTime(Date time, int timeStyle) {
279        CheckParameterUtil.ensureParameterNotNull(time, "time");
280        return getTimeFormat(timeStyle).format(time);
281    }
282
283    /**
284     * Returns the date/time format to be used for current user, based on user preferences.
285     * @param dateStyle The date style as described in {@link DateFormat#getDateTimeInstance}. Ignored if "ISO dates" option is set
286     * @param timeStyle The time style as described in {@code DateFormat.getDateTimeInstance}. Ignored if "ISO dates" option is set
287     * @return The date/time format
288     * @since 7299
289     */
290    public static DateFormat getDateTimeFormat(int dateStyle, int timeStyle) {
291        if (PROP_ISO_DATES.get()) {
292            // This is not strictly conform to ISO 8601. We just want to avoid US-style times such as 3.30pm
293            // and we don't want to use the 'T' separator as a space character is much more readable
294            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
295        } else {
296            return DateFormat.getDateTimeInstance(dateStyle, timeStyle, Locale.getDefault());
297        }
298    }
299
300    /**
301     * Formats a date/time to be displayed to current user, based on user preferences.
302     * @param datetime The date/time to display. Must not be {@code null}
303     * @param dateStyle The date style as described in {@link DateFormat#getDateTimeInstance}. Ignored if "ISO dates" option is set
304     * @param timeStyle The time style as described in {@code DateFormat.getDateTimeInstance}. Ignored if "ISO dates" option is set
305     * @return The formatted date/time
306     * @since 7299
307     */
308    public static String formatDateTime(Date datetime, int dateStyle, int timeStyle) {
309        CheckParameterUtil.ensureParameterNotNull(datetime, "datetime");
310        return getDateTimeFormat(dateStyle, timeStyle).format(datetime);
311    }
312
313    /**
314     * Allows to override the timezone for unit tests.
315     * @param zone the timezone to use
316     */
317    protected static synchronized void setTimeZone(TimeZone zone) {
318        calendarLocale.setTimeZone(zone);
319    }
320}