    001/*
    002 * Licensed to the Apache Software Foundation (ASF) under one or more
    003 * contributor license agreements.  See the NOTICE file distributed with
    004 * this work for additional information regarding copyright ownership.
    005 * The ASF licenses this file to You under the Apache License, Version 2.0
    006 * (the "License"); you may not use this file except in compliance with
    007 * the License.  You may obtain a copy of the License at
    008 *
    009 *      http://www.apache.org/licenses/LICENSE-2.0
    010 *
    011 * Unless required by applicable law or agreed to in writing, software
    012 * distributed under the License is distributed on an "AS IS" BASIS,
    013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    014 * See the License for the specific language governing permissions and
    015 * limitations under the License.
    016 */
    017
    018package org.apache.commons.net.ftp.parser;
    019
    020import java.text.DateFormatSymbols;
    021import java.text.ParseException;
    022import java.text.ParsePosition;
    023import java.text.SimpleDateFormat;
    024import java.util.Calendar;
    025import java.util.Date;
    026import java.util.TimeZone;
    027
    028import org.apache.commons.net.ftp.Configurable;
    029import org.apache.commons.net.ftp.FTPClientConfig;
    030
    031/**
    032 * Default implementation of the {@link  FTPTimestampParser  FTPTimestampParser}
    033 * interface also implements the {@link  org.apache.commons.net.ftp.Configurable  Configurable}
    034 * interface to allow the parsing to be configured from the outside.
    035 *
    036 * @see ConfigurableFTPFileEntryParserImpl
    037 * @since 1.4
    038 */
    039public class FTPTimestampParserImpl implements
    040        FTPTimestampParser, Configurable
    041{
    042
    043
    044    /** The date format for all dates, except possibly recent dates. Assumed to include the year. */
    045    private SimpleDateFormat defaultDateFormat;
    046    /* The index in CALENDAR_UNITS of the smallest time unit in defaultDateFormat */
    047    private int defaultDateSmallestUnitIndex;
    048
    049    /** The format used for recent dates (which don't have the year). May be null. */
    050    private SimpleDateFormat recentDateFormat;
    051    /* The index in CALENDAR_UNITS of the smallest time unit in recentDateFormat */
    052    private int recentDateSmallestUnitIndex;
    053
    054    private boolean lenientFutureDates = false;
    055
    056    /*
    057     * List of units in order of increasing significance.
    058     * This allows the code to clear all units in the Calendar until it
    059     * reaches the least significant unit in the parse string.
    060     * The date formats are analysed to find the least significant
    061     * unit (e.g. Minutes or Milliseconds) and the appropriate index to
    062     * the array is saved.
    063     * This is done by searching the array for the unit specifier,
    064     * and returning the index. When clearing the Calendar units,
    065     * the code loops through the array until the previous entry.
    066     * e.g. for MINUTE it would clear MILLISECOND and SECOND
    067     */
    068    private static final int[] CALENDAR_UNITS = {
    069        Calendar.MILLISECOND,
    070        Calendar.SECOND,
    071        Calendar.MINUTE,
    072        Calendar.HOUR_OF_DAY,
    073        Calendar.DAY_OF_MONTH,
    074        Calendar.MONTH,
    075        Calendar.YEAR};
    076
    077    /*
    078     * Return the index to the array representing the least significant
    079     * unit found in the date format.
    080     * Default is 0 (to avoid dropping precision)
    081     */
    082    private static int getEntry(final SimpleDateFormat dateFormat) {
    083        if (dateFormat == null) {
    084            return 0;
    085        }
    086        final String FORMAT_CHARS="SsmHdM";
    087        final String pattern = dateFormat.toPattern();
    088        for(final char ch : FORMAT_CHARS.toCharArray()) {
    089            if (pattern.indexOf(ch) != -1){ // found the character
    090                switch(ch) {
    091                case 'S':
    092                    return indexOf(Calendar.MILLISECOND);
    093                case 's':
    094                    return indexOf(Calendar.SECOND);
    095                case 'm':
    096                    return indexOf(Calendar.MINUTE);
    097                case 'H':
    098                    return indexOf(Calendar.HOUR_OF_DAY);
    099                case 'd':
    100                    return indexOf(Calendar.DAY_OF_MONTH);
    101                case 'M':
    102                    return indexOf(Calendar.MONTH);
    103                }
    104            }
    105        }
    106        return 0;
    107    }
    108
    109    /*
    110     * Find the entry in the CALENDAR_UNITS array.
    111     */
    112    private static int indexOf(final int calendarUnit) {
    113        int i;
    114        for(i = 0; i <CALENDAR_UNITS.length; i++) {
    115            if (calendarUnit == CALENDAR_UNITS[i]) {
    116                return i;
    117            }
    118        }
    119        return 0;
    120    }
    121
    122    /*
    123     * Sets the Calendar precision (used by FTPFile#toFormattedDate) by clearing
    124     * the immediately preceeding unit (if any).
    125     * Unfortunately the clear(int) method results in setting all other units.
    126     */
    127    private static void setPrecision(final int index, final Calendar working) {
    128        if (index <= 0) { // e.g. MILLISECONDS
    129            return;
    130        }
    131        final int field = CALENDAR_UNITS[index-1];
    132        // Just in case the analysis is wrong, stop clearing if
    133        // field value is not the default.
    134        final int value = working.get(field);
    135        if (value != 0) { // don't reset if it has a value
    136//            new Throwable("Unexpected value "+value).printStackTrace(); // DEBUG
    137        } else {
    138            working.clear(field); // reset just the required field
    139        }
    140    }
    141
    142    /**
    143     * The only constructor for this class.
    144     */
    145    public FTPTimestampParserImpl() {
    146        setDefaultDateFormat(DEFAULT_SDF, null);
    147        setRecentDateFormat(DEFAULT_RECENT_SDF, null);
    148    }
    149
    150    /**
    151     * Implements the one {@link  FTPTimestampParser#parseTimestamp(String)  method}
    152     * in the {@link  FTPTimestampParser  FTPTimestampParser} interface
    153     * according to this algorithm:
    154     *
    155     * If the recentDateFormat member has been defined, try to parse the
    156     * supplied string with that.  If that parse fails, or if the recentDateFormat
    157     * member has not been defined, attempt to parse with the defaultDateFormat
    158     * member.  If that fails, throw a ParseException.
    159     *
    160     * This method assumes that the server time is the same as the local time.
    161     *
    162     * @see FTPTimestampParserImpl#parseTimestamp(String, Calendar)
    163     *
    164     * @param timestampStr The timestamp to be parsed
    165     * @return a Calendar with the parsed timestamp
    166     */
    167    @Override
    168    public Calendar parseTimestamp(final String timestampStr) throws ParseException {
    169        final Calendar now = Calendar.getInstance();
    170        return parseTimestamp(timestampStr, now);
    171    }
    172
    173    /**
    174     * If the recentDateFormat member has been defined, try to parse the
    175     * supplied string with that.  If that parse fails, or if the recentDateFormat
    176     * member has not been defined, attempt to parse with the defaultDateFormat
    177     * member.  If that fails, throw a ParseException.
    178     *
    179     * This method allows a {@link Calendar} instance to be passed in which represents the
    180     * current (system) time.
    181     *
    182     * @see FTPTimestampParser#parseTimestamp(String)
    183     * @param timestampStr The timestamp to be parsed
    184     * @param serverTime The current time for the server
    185     * @return the calendar
    186     * @throws ParseException if timestamp cannot be parsed
    187     * @since 1.5
    188     */
    189    public Calendar parseTimestamp(final String timestampStr, final Calendar serverTime) throws ParseException {
    190        final Calendar working = (Calendar) serverTime.clone();
    191        working.setTimeZone(getServerTimeZone()); // is this needed?
    192
    193        Date parsed = null;
    194
    195        if (recentDateFormat != null) {
    196            final Calendar now = (Calendar) serverTime.clone();// Copy this, because we may change it
    197            now.setTimeZone(this.getServerTimeZone());
    198            if (lenientFutureDates) {
    199                // add a day to "now" so that "slop" doesn't cause a date
    200                // slightly in the future to roll back a full year.  (Bug 35181 => NET-83)
    201                now.add(Calendar.DAY_OF_MONTH, 1);
    202            }
    203            // The Java SimpleDateFormat class uses the epoch year 1970 if not present in the input
    204            // As 1970 was not a leap year, it cannot parse "Feb 29" correctly.
    205            // Java 1.5+ returns Mar 1 1970
    206            // Temporarily add the current year to the short date time
    207            // to cope with short-date leap year strings.
    208            // Since Feb 29 is more that 6 months from the end of the year, this should be OK for
    209            // all instances of short dates which are +- 6 months from current date.
    210            // TODO this won't always work for systems that use short dates +0/-12months
    211            // e.g. if today is Jan 1 2001 and the short date is Feb 29
    212            final String year = Integer.toString(now.get(Calendar.YEAR));
    213            final String timeStampStrPlusYear = timestampStr + " " + year;
    214            final SimpleDateFormat hackFormatter = new SimpleDateFormat(recentDateFormat.toPattern() + " yyyy",
    215                    recentDateFormat.getDateFormatSymbols());
    216            hackFormatter.setLenient(false);
    217            hackFormatter.setTimeZone(recentDateFormat.getTimeZone());
    218            final ParsePosition pp = new ParsePosition(0);
    219            parsed = hackFormatter.parse(timeStampStrPlusYear, pp);
    220            // Check if we parsed the full string, if so it must have been a short date originally
    221            if (parsed != null && pp.getIndex() == timeStampStrPlusYear.length()) {
    222                working.setTime(parsed);
    223                if (working.after(now)) { // must have been last year instead
    224                    working.add(Calendar.YEAR, -1);
    225                }
    226                setPrecision(recentDateSmallestUnitIndex, working);
    227                return working;
    228            }
    229        }
    230
    231        final ParsePosition pp = new ParsePosition(0);
    232        parsed = defaultDateFormat.parse(timestampStr, pp);
    233        // note, length checks are mandatory for us since
    234        // SimpleDateFormat methods will succeed if less than
    235        // full string is matched.  They will also accept,
    236        // despite "leniency" setting, a two-digit number as
    237        // a valid year (e.g. 22:04 will parse as 22 A.D.)
    238        // so could mistakenly confuse an hour with a year,
    239        // if we don't insist on full length parsing.
    240        if (parsed != null && pp.getIndex() == timestampStr.length()) {
    241            working.setTime(parsed);
    242        } else {
    243            throw new ParseException(
    244                    "Timestamp '"+timestampStr+"' could not be parsed using a server time of "
    245                        +serverTime.getTime().toString(),
    246                    pp.getErrorIndex());
    247        }
    248        setPrecision(defaultDateSmallestUnitIndex, working);
    249        return working;
    250    }
    251
    252    /**
    253     * @return Returns the defaultDateFormat.
    254     */
    255    public SimpleDateFormat getDefaultDateFormat() {
    256        return defaultDateFormat;
    257    }
    258    /**
    259     * @return Returns the defaultDateFormat pattern string.
    260     */
    261    public String getDefaultDateFormatString() {
    262        return defaultDateFormat.toPattern();
    263    }
    264    /**
    265     * @param format The defaultDateFormat to be set.
    266     * @param dfs the symbols to use (may be null)
    267     */
    268    private void setDefaultDateFormat(final String format, final DateFormatSymbols dfs) {
    269        if (format != null) {
    270            if (dfs != null) {
    271                this.defaultDateFormat = new SimpleDateFormat(format, dfs);
    272            } else {
    273                this.defaultDateFormat = new SimpleDateFormat(format);
    274            }
    275            this.defaultDateFormat.setLenient(false);
    276        } else {
    277            this.defaultDateFormat = null;
    278        }
    279        this.defaultDateSmallestUnitIndex = getEntry(this.defaultDateFormat);
    280    }
    281    /**
    282     * @return Returns the recentDateFormat.
    283     */
    284    public SimpleDateFormat getRecentDateFormat() {
    285        return recentDateFormat;
    286    }
    287    /**
    288     * @return Returns the recentDateFormat.
    289     */
    290    public String getRecentDateFormatString() {
    291        return recentDateFormat.toPattern();
    292    }
    293    /**
    294     * @param format The recentDateFormat to set.
    295     * @param dfs the symbols to use (may be null)
    296     */
    297    private void setRecentDateFormat(final String format, final DateFormatSymbols dfs) {
    298        if (format != null) {
    299            if (dfs != null) {
    300                this.recentDateFormat = new SimpleDateFormat(format, dfs);
    301            } else {
    302                this.recentDateFormat = new SimpleDateFormat(format);
    303            }
    304            this.recentDateFormat.setLenient(false);
    305        } else {
    306            this.recentDateFormat = null;
    307        }
    308        this.recentDateSmallestUnitIndex = getEntry(this.recentDateFormat);
    309    }
    310
    311    /**
    312     * @return returns an array of 12 strings representing the short
    313     * month names used by this parse.
    314     */
    315    public String[] getShortMonths() {
    316        return defaultDateFormat.getDateFormatSymbols().getShortMonths();
    317    }
    318
    319
    320    /**
    321     * @return Returns the serverTimeZone used by this parser.
    322     */
    323    public TimeZone getServerTimeZone() {
    324        return this.defaultDateFormat.getTimeZone();
    325    }
    326    /**
    327     * sets a TimeZone represented by the supplied ID string into all
    328     * of the parsers used by this server.
    329     * @param serverTimeZone Time Id java.util.TimeZone id used by
    330     * the ftp server.  If null the client's local time zone is assumed.
    331     */
    332    private void setServerTimeZone(final String serverTimeZoneId) {
    333        TimeZone serverTimeZone = TimeZone.getDefault();
    334        if (serverTimeZoneId != null) {
    335            serverTimeZone = TimeZone.getTimeZone(serverTimeZoneId);
    336        }
    337        this.defaultDateFormat.setTimeZone(serverTimeZone);
    338        if (this.recentDateFormat != null) {
    339            this.recentDateFormat.setTimeZone(serverTimeZone);
    340        }
    341    }
    342
    343    /**
    344     * Implementation of the {@link  Configurable  Configurable}
    345     * interface. Configures this <code>FTPTimestampParser</code> according
    346     * to the following logic:
    347     * <p>
    348     * Set up the {@link  FTPClientConfig#setDefaultDateFormatStr(java.lang.String) defaultDateFormat}
    349     * and optionally the {@link  FTPClientConfig#setRecentDateFormatStr(String) recentDateFormat}
    350     * to values supplied in the config based on month names configured as follows:
    351     * </p>
    352     * <ul>
    353     * <li>If a {@link  FTPClientConfig#setShortMonthNames(String) shortMonthString}
    354     * has been supplied in the <code>config</code>, use that to parse  parse timestamps.</li>
    355     * <li>Otherwise, if a {@link  FTPClientConfig#setServerLanguageCode(String) serverLanguageCode}
    356     * has been supplied in the <code>config</code>, use the month names represented
    357     * by that {@link  FTPClientConfig#lookupDateFormatSymbols(String) language}
    358     * to parse timestamps.</li>
    359     * <li>otherwise use default English month names</li>
    360     * </ul><p>
    361     * Finally if a {@link  org.apache.commons.net.ftp.FTPClientConfig#setServerTimeZoneId(String) serverTimeZoneId}
    362     * has been supplied via the config, set that into all date formats that have
    363     * been configured.
    364     * </p>
    365     */
    366    @Override
    367    public void configure(final FTPClientConfig config) {
    368        DateFormatSymbols dfs = null;
    369
    370        final String languageCode = config.getServerLanguageCode();
    371        final String shortmonths = config.getShortMonthNames();
    372        if (shortmonths != null) {
    373            dfs = FTPClientConfig.getDateFormatSymbols(shortmonths);
    374        } else if (languageCode != null) {
    375            dfs = FTPClientConfig.lookupDateFormatSymbols(languageCode);
    376        } else {
    377            dfs = FTPClientConfig.lookupDateFormatSymbols("en");
    378        }
    379
    380
    381        final String recentFormatString = config.getRecentDateFormatStr();
    382        setRecentDateFormat(recentFormatString, dfs);
    383
    384        final String defaultFormatString = config.getDefaultDateFormatStr();
    385        if (defaultFormatString == null) {
    386            throw new IllegalArgumentException("defaultFormatString cannot be null");
    387        }
    388        setDefaultDateFormat(defaultFormatString, dfs);
    389
    390        setServerTimeZone(config.getServerTimeZoneId());
    391
    392        this.lenientFutureDates = config.isLenientFutureDates();
    393    }
    394    /**
    395     * @return Returns the lenientFutureDates.
    396     */
    397    boolean isLenientFutureDates() {
    398        return lenientFutureDates;
    399    }
    400    /**
    401     * @param lenientFutureDates The lenientFutureDates to set.
    402     */
    403    void setLenientFutureDates(final boolean lenientFutureDates) {
    404        this.lenientFutureDates = lenientFutureDates;
    405    }
    406}