/* * Copyright 2008 Fedora Commons, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.mulgara.util; import java.text.ParseException; import java.nio.ByteBuffer; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.joda.time.format.DateTimeFormat; import org.joda.time.format.DateTimeFormatter; import static org.joda.time.DateTimeZone.UTC; import static org.mulgara.util.Constants.SIZEOF_LONG; /** * This class represents a dateTime value, preserving its lexical representation exactly. * It stores the value of the dateTime in the canonical form, but also contains values which * allow the preservation of the non-canonical format. * * @created Jun 5, 2008 * @author Paula Gearon * @copyright © 2008 <a href="http://www.fedora-commons.org/">Fedora Commons</a> */ public class LexicalDateTime { /** The character for separating date elements */ private static final char DATE_SEPARATOR = '-'; /** The character for separating the date part from the time part */ private static final char DATE_TIME_SEPARATOR = 'T'; /** The character for separating time elements */ private static final char TIME_SEPARATOR = ':'; /** The character for separating the milliseconds from the seconds */ private static final char MILLI_SEPARATOR = '.'; /** The string form for the character separating the milliseconds from the seconds */ private static final String MILLI_SEPARATOR_STR = "."; /** The character for indicating the UTC timezone (Zulu time). */ private static final char ZULU = 'Z'; /** The string form for the character indicating the UTC timezone (Zulu time). */ private static final String ZULU_STR = "Z"; /** The character for indicating a positive timezone offset */ private static final char POS_TZ = '+'; /** The character for indicating a negative timezone offset */ private static final char NEG_TZ = '-'; /** The hour value for midnight */ private static final int MIDNIGHT = 24; /** The string representation of midnight when the midnight flag is set */ private static final String MIDNIGHT_STR = "24:00:00"; /** Standard start of parsing error messages */ private static final String BAD_FORMAT = "Bad format in "; /** Output format for the dateTime */ private static final String LEXICAL_PATTERN = "yyyy-MM-dd'T'HH:mm:ss"; /** Output format for the date portion of the dateTime */ private static final String SHORT_PATTERN = "yyyy-MM-dd'T'"; /** The formatter used for converting the dateTime into a lexical form */ private static final DateTimeFormatter MAIN_FORMATTER = DateTimeFormat.forPattern(LEXICAL_PATTERN); /** A supplemantary formatter for outputting the date, when the time has to be represented in non-canonical form */ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormat.forPattern(SHORT_PATTERN); /** The number of milliseconds in a second */ private static final int MILLIS = 1000; /** The number of milliseconds in a minute */ private static final int MILLIS_IN_MINUTE = MILLIS * 60; /** The number of milliseconds in an hour */ private static final long MILLIS_IN_HOUR = MILLIS_IN_MINUTE * 60; /** The bit used to encode the localFlag */ private static final byte LOCAL_BIT = 0x02; /** The bit used to encode the midnightFlag */ private static final byte MIDNIGHT_BIT = 0x01; /** The mask for the timezone bits */ private static final byte TZ_MASK = (byte)0xFC; /** The offset of the timezone data in an encoded buffer */ private static final int TZ_OFFSET = SIZEOF_LONG; /** The offset of the fractional seconds decimal places in an encoded buffer */ private static final int PLACES_OFFSET = TZ_OFFSET + 1; /** The milliseconds since the epoch */ private final long millis; /** The hours offset for the time */ private final int tzHours; /** The minutes offset for the timezone. A multiple of 15. */ private final int tzMinutes; /** Indicates that the time was supplied as 24:00:00. */ private final boolean midnight; /** The number of decimal places used to represent the milliseconds. No greater than 3. */ private final byte milliPlaces; /** Indicates no supplied timezone. This defaults to the local timezone. */ private final boolean localFlag; /** Indicates ZULU time, which is equivalent to +00:00. */ private final boolean zuluFlag; /** A DateTime corresponding to this object. Only created if needed. */ private DateTime cachedDateTime = null; /** * This constructor is used to set each field explicitly, when all such information is available. * No checking is performed on the consistency of the millisecond value, though some minimal testing is done on flags. * Whether tested or not, the following should hold: * <ul> * <li>if <code>isMidnight</code> is set, then <code>millis</code> must be a multiple of 24 hours.</li> * <li>if <code>isLocalTz</code> is set, then <code>tzHours</code> and <code>tzMinutes</code> should be 0, * and <code>isZulu</code> must be false.</li> * <li>if <code>isZulu</code> is set, then <code>tzHours</code> and <code>tzMinutes</code> must be 0.</li> * <li><code>millis</code> % 10^(6 - milliPlaces) == 0</li> * </ul> * @param millis The milliseconds since the epoch. * @param tzHours The hour offset for the timezone. * @param tzMinutes The minute offset for the timezone. * @param isMidnight If the non-canonical form for midnight is used. "24:00:00" * @param milliPlaces The number of decimal places used for representing millisecds as fractions of a second. * @param isLocalTz Indicates no timezone information, so use the local default. * @param isZulu Indicates that the timezone is "Zulu". This is equivalent to 00:00 and is represented as "Z". * @throws IllegalArgumentException if the <code>isZulu</code> flag conflicts with the timezone values or the offsets. */ public LexicalDateTime(long millis, int tzHours, int tzMinutes, boolean isMidnight, byte milliPlaces, boolean isLocalTz, boolean isZulu) { this.millis = millis; this.tzHours = tzHours; this.tzMinutes = tzMinutes; this.midnight = isMidnight; this.milliPlaces = milliPlaces; this.localFlag = isLocalTz; this.zuluFlag = isZulu; testTimezoneConsistency(); } /** * Internal constructor used with a dateTime object, and parsed fields. * No checking is performed on the consistency of the millisecond value, though some minimal testing is done on flags. * Whether tested or not, the following should hold: * <ul> * <li>if <code>isMidnight</code> is set, then <code>millis</code> plus the timezone offset must be a multiple * of 24 hours.</li> * <li>if <code>isLocalTz</code> is set, then <code>tzHours</code> and <code>tzMinutes</code> should be 0, * and <code>isZulu</code> must be false.</li> * <li>if <code>isZulu</code> is set, then <code>tzHours</code> and <code>tzMinutes</code> must be 0.</li> * <li><code>millis</code> % 10^(3 - milliPlaces) == 0</li> * <li>The <code>tzHours</code> and <code>tzMinutes</code> values should correspond to the values in the * <code>dateTime</code> field.</li> * </ul> * @param dateTime The dateTime object representing the time. * @param tzHours The hour offset for the timezone. * @param tzMinutes The minute offset for the timezone. * @param isMidnight If the non-canonical form for midnight is used. "24:00:00" * @param milliPlaces The number of decimal places used for representing millisecds as fractions of a second. * @param isLocalTz Indicates no timezone information, so use the local default. * @param isZulu Indicates that the timezone is "Zulu". This is equivalent to 00:00 and is represented as "Z". * @throws IllegalArgumentException if the <code>isZulu</code> flag conflicts with the timezone values or the offsets. */ private LexicalDateTime(DateTime dateTime, int tzHours, int tzMinutes, boolean isMidnight, byte milliPlaces, boolean isLocalTz, boolean isZulu) { this.millis = dateTime.getMillis(); this.tzHours = tzHours; this.tzMinutes = tzMinutes; this.midnight = isMidnight; this.cachedDateTime = dateTime; this.milliPlaces = milliPlaces; this.localFlag = isLocalTz; this.zuluFlag = isZulu; testTimezoneConsistency(); } /** * Convenience constructor which allows easy construction of a LexicalDateTime using the milliseconds since the epoch. * @param millis Milliseconds since the epoch. */ public LexicalDateTime(long millis) { this.millis = millis; long offset = DateTimeZone.getDefault().getOffset(0); tzHours = (int)(offset / MILLIS_IN_HOUR); tzMinutes = (int)(offset % MILLIS_IN_HOUR) / MILLIS_IN_MINUTE; midnight = false; cachedDateTime = null; localFlag = true; zuluFlag = false; milliPlaces = minimumPlaces(millis); } /** Gets the number of milliseconds since the epoch. */ public long getMillis() { return millis; } /** The the hour part of the offset for the timezone. */ public int getTZHour() { return tzHours; } /** The the minute part of the offset for the timezone. */ public long getTZMinute() { return tzMinutes; } /** Gets the flag that indicates that this time is a non-canonical form of midnight. */ public boolean isMidnight() { return midnight; } /** Gets the flag that indicates no timezone is present, and the local default should be used. */ public boolean isLocal() { return localFlag; } /** Gets the flag that indicates the Zulu timezone (UTC) and representation. */ public boolean isZulu() { return zuluFlag; } /** Gets the number of decimal places to represent the fraction of a second. */ public byte getDecimalPlaces() { return milliPlaces; } /** Get the size of buffer in bytes required to store this object */ public static int requiredBufferSize() { return PLACES_OFFSET + 1; } /** * Fills in a ByteBuffer with the data required to encode this object. * @param bb The {@link java.nio.ByteBuffer} to populate. * @return The populated ByteBuffer. */ public ByteBuffer encode(ByteBuffer bb) { assert bb.limit() > PLACES_OFFSET; bb.putLong(0, millis); bb.put(TZ_OFFSET, encodeTimezoneState()); bb.put(PLACES_OFFSET, milliPlaces); return bb; } /** * Creates a byte code for the timezone and flags of this dateTime. * <table> * <tr><td>bits 7-2</td><td>timezone code</td></tr> * <tr><td >bit 1</td><td>local flag</td></tr> * <tr><td>bit 0</td><td>midnight flag</td></tr> * </table> * @return a byte containing the timezone data. */ public byte encodeTimezoneState() { byte result = 0; if (zuluFlag) result = Timezone.getZuluCode(); else result = new Timezone(tzHours, tzMinutes).getCode(); if (localFlag) result |= LOCAL_BIT; if (midnight) result |= MIDNIGHT_BIT; return result; } /** * Decodes a {@link ByteBuffer} into a LexicalDateTime. * @param bb The ByteBuffer to decode. * @return a new LexicalDateTime structure. */ public static LexicalDateTime decode(ByteBuffer bb) { assert bb.limit() > PLACES_OFFSET; return decode(bb.getLong(0), bb.get(TZ_OFFSET), bb.get(PLACES_OFFSET)); } /** * Decodes a millisecond value and an encoded byte into a timezone and flags. * @param millis The milliseconds since the epoch. * @param timezoneState The encoded data representing the timezone. * @param places The number of decimal places for the seconds representation. * @return a new LexicalDateTime structure. */ public static LexicalDateTime decode(long millis, byte timezoneState, byte places) { boolean local = (timezoneState & LOCAL_BIT) != 0; boolean midnight = (timezoneState & MIDNIGHT_BIT) != 0; byte tzCode = (byte)(timezoneState & TZ_MASK); boolean zulu = (tzCode == Timezone.getZuluCode()); Timezone tz = new Timezone(tzCode); return new LexicalDateTime(millis, tz.getHour(), tz.getMinute(), midnight, places, local, zulu); } /** Return a lexical representation of this dateTime. */ public String toString() { if (cachedDateTime == null) { DateTimeZone dtz; dtz = (localFlag) ? null : DateTimeZone.forOffsetHoursMinutes(tzHours, tzMinutes); cachedDateTime = new DateTime(millis, dtz); } StringBuilder result; if (!midnight) { result = new StringBuilder(MAIN_FORMATTER.print(cachedDateTime)); if (milliPlaces > 0) { result.append(MILLI_SEPARATOR_STR); int place = MILLIS; long fraction = millis; if (fraction < 0) fraction = fraction % place + place; for (int m = 0; m < milliPlaces; m++) { fraction = fraction % place; place /= 10; result.append(fraction / place); } } } else { result = new StringBuilder(DATE_FORMATTER.print(cachedDateTime.plusDays(-1))); result.append(MIDNIGHT_STR); if (milliPlaces > 0) { result.append(MILLI_SEPARATOR_STR); for (int i = 0; i < milliPlaces; i++) result.append("0"); } } if (!localFlag) { if (zuluFlag) result.append(ZULU_STR); else result.append(String.format("%+03d:%02d", tzHours, tzMinutes)); } return result.toString(); } /** * Parse a dateTime string. It <strong>must</strong> be of the form: * ('-')? yyyy '-' MM '-' dd 'T' hh ':' mm ':' ss ( '.' s+ )? ( ( ('+'|'-')? hh ':' mm ) | 'Z' )? * @param dt The dateTime string to parse. * @return a new LexcalDateTime value. * @throws ParseException If a character that doesn't match the above pattern is discovered. */ public static LexicalDateTime parseDateTime(String dt) throws ParseException { int pos = 0; try { boolean negative = dt.charAt(pos) == '-'; if (negative) pos++; int year = d(dt, pos++) * 1000 + d(dt, pos++) * 100 + d(dt, pos++) * 10 + d(dt, pos++); while (dt.charAt(pos) != DATE_SEPARATOR) year = year * 10 + d(dt, pos++); if (negative) year = -year; if (dt.charAt(pos++) != DATE_SEPARATOR) throw new ParseException(BAD_FORMAT + "date: " + dt, pos - 1); int month = d(dt, pos++) * 10 + d(dt, pos++); if (dt.charAt(pos++) != DATE_SEPARATOR) throw new ParseException(BAD_FORMAT + "date: " + dt, pos - 1); int day = d(dt, pos++) * 10 + d(dt, pos++); if (dt.charAt(pos++) != DATE_TIME_SEPARATOR) throw new ParseException(BAD_FORMAT + "date/time: " + dt, pos - 1); int hour = d(dt, pos++) * 10 + d(dt, pos++); if (dt.charAt(pos++) != TIME_SEPARATOR) throw new ParseException(BAD_FORMAT + "time: " + dt, pos - 1); int minute = d(dt, pos++) * 10 + d(dt, pos++); if (dt.charAt(pos++) != TIME_SEPARATOR) throw new ParseException(BAD_FORMAT + "time: " + dt, pos - 1); int second = d(dt, pos++) * 10 + d(dt, pos++); int millisecs = 0; byte milliPlaces = 0; int lastPos = dt.length() - 1; if (pos < lastPos) { if (dt.charAt(pos) == MILLI_SEPARATOR) { int place = MILLIS / 10; int digit; while (isDecimal((digit = dt.charAt(++pos) - '0'))) { millisecs += digit * place; if (milliPlaces++ > 3) throw new ParseException(BAD_FORMAT + "milliseconds: " + dt, pos); place /= 10; if (pos == lastPos) { pos++; break; } } } } boolean midnightFlag = false; if (hour == MIDNIGHT) { midnightFlag = true; hour = 0; } if (midnightFlag && (minute > 0 || second > 0 || millisecs > 0)) throw new ParseException(BAD_FORMAT + "time: " + dt, pos); boolean local = false; int tzHour = 0; int tzMinute = 0; boolean zuluFlag = false; DateTimeZone timezone = null; if (pos <= lastPos) { char tz = dt.charAt(pos++); if (tz == ZULU) { if (pos != lastPos + 1) throw new ParseException(BAD_FORMAT + "timezone: " + dt, pos); timezone = UTC; zuluFlag = true; } else { if (pos != lastPos - 4 || (tz != NEG_TZ && tz != POS_TZ)) throw new ParseException(BAD_FORMAT + "timezone: " + dt, pos); tzHour = d(dt, pos++) * 10 + d(dt, pos++); if (dt.charAt(pos++) != TIME_SEPARATOR) throw new ParseException(BAD_FORMAT + "timezone: " + dt, pos - 1); tzMinute = d(dt, pos++) * 10 + d(dt, pos++); if (tz == NEG_TZ) tzHour = -tzHour; timezone = DateTimeZone.forOffsetHoursMinutes(tzHour, tzMinute); } } else { local = true; } DateTime dateTime = new DateTime(year, month, day, hour, minute, second, millisecs, timezone); if (midnightFlag) dateTime = dateTime.plusDays(1); return new LexicalDateTime(dateTime, tzHour, tzMinute, midnightFlag, milliPlaces, local, zuluFlag); } catch (StringIndexOutOfBoundsException e) { throw new IllegalArgumentException(BAD_FORMAT + "date: " + dt); } } /** {@inheritDoc} */ public boolean equals(Object o) { if (!(o instanceof LexicalDateTime)) return false; LexicalDateTime other = (LexicalDateTime)o; return millis == other.millis && tzHours == other.tzHours && tzMinutes == other.tzMinutes && milliPlaces == other.milliPlaces && localFlag == other.localFlag && zuluFlag == other.zuluFlag && midnight == other.midnight; } /** {@inheritDoc} */ public int hashCode() { return Long.valueOf(millis).hashCode() + encodeTimezoneState() * 13; } /** * Check that the timezone flags are consistent with one another. * @throws IllegalArgumentException if the is an inconsistency in the timezone values. */ private void testTimezoneConsistency() { if (zuluFlag) { if (localFlag) throw new IllegalArgumentException("Cannot have Zulu time and a \"default\" timezone"); if (tzHours != 0 || tzMinutes != 0) throw new IllegalArgumentException("Cannot have Zulu time and a timezone offset"); } assert (millis % (int)Math.pow(10, 3 - milliPlaces)) == 0; } /** * Extract a single decimal digit from a string. * @param str The string to get the digit from. * @param i The location in the string to extract the digit from * @return The extracted digit. * @throws ParseException If the character to be extracted is not a decimal digit. */ private static int d(String str, int i) throws ParseException { int d = str.charAt(i) - '0'; if (d >= 10 || d < 0) throw new ParseException("Unexpected character: " + Character.toString(str.charAt(i)) + ". Expected numeric digit.", i); return d; } /** * Tests if a number represents a single decimal digit. * @param i The number to test. * @return <code>true</code> if the number represents a single decimal digit. */ private static boolean isDecimal(int i) { return i < 10 && i >= 0; } /** * Determine the minimum number of decimal places required to represent * a millisecond value in seconds. * @param mSec The number of milliseconds to represent. * @return The minimum number of decimal places needed when representing mSec in seconds. * This result is always in the range 0-3. */ private static byte minimumPlaces(long mSec) { byte p = 3; int precision = 1; for (; p > 0; p--) { precision *= 10; if (mSec % precision != 0) break; } return p; } }