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(SimpleDateFormat dateFormat) {
083        if (dateFormat == null) {
084            return 0;
085        }
086        final String FORMAT_CHARS="SsmHdM";
087        final String pattern = dateFormat.toPattern();
088        for(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(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(int index, 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(String timestampStr) throws ParseException {
169        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(String timestampStr, Calendar serverTime) throws ParseException {
190        Calendar working = (Calendar) serverTime.clone();
191        working.setTimeZone(getServerTimeZone()); // is this needed?
192
193        Date parsed = null;
194
195        if (recentDateFormat != null) {
196            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            String year = Integer.toString(now.get(Calendar.YEAR));
213            String timeStampStrPlusYear = timestampStr + " " + year;
214            SimpleDateFormat hackFormatter = new SimpleDateFormat(recentDateFormat.toPattern() + " yyyy",
215                    recentDateFormat.getDateFormatSymbols());
216            hackFormatter.setLenient(false);
217            hackFormatter.setTimeZone(recentDateFormat.getTimeZone());
218            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        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(String format, 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(String format, 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(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(FTPClientConfig config) {
368        DateFormatSymbols dfs = null;
369
370        String languageCode = config.getServerLanguageCode();
371        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        String recentFormatString = config.getRecentDateFormatStr();
382        setRecentDateFormat(recentFormatString, dfs);
383
384        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(boolean lenientFutureDates) {
404        this.lenientFutureDates = lenientFutureDates;
405    }
406}