DateTimeFormatParser.java

package emissary.util;

import emissary.config.ConfigUtil;
import emissary.config.Configurator;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import javax.annotation.Nullable;

/**
 * Class for Parsing Dates using DateTimeFormatter. Attempts to parse a date of an unknown format with a predefined set
 * of formats.
 */
public class DateTimeFormatParser {

    protected static final List<DateTimeFormatter> dateTimeZoneFormats = new ArrayList<>();
    protected static final List<DateTimeFormatter> dateTimeOffsetFormats = new ArrayList<>();
    protected static final List<DateTimeFormatter> dateTimeFormats = new ArrayList<>();
    protected static final List<DateTimeFormatter> dateFormats = new ArrayList<>();
    @SuppressWarnings("NonFinalStaticField")
    protected static ZoneId zone;

    private static final String DATE_TIME_ZONE_FORMAT = "DATE_TIME_ZONE_FORMAT";
    private static final String DATE_TIME_OFFSET_FORMAT = "DATE_TIME_OFFSET_FORMAT";
    private static final String DATE_TIME_FORMAT = "DATE_TIME_FORMAT";
    private static final String DATE_FORMAT = "DATE_FORMAT";

    protected static final Logger logger = LoggerFactory.getLogger(DateTimeFormatParser.class);

    static {
        configure();
    }

    private DateTimeFormatParser() {}

    protected static void configure() {

        Configurator configG;
        try {
            configG = ConfigUtil.getConfigInfo(DateTimeFormatParser.class);
        } catch (IOException e) {
            logger.error("Cannot open default config file", e);
            return;
        }
        try {
            zone = ZoneId.of(configG.findStringEntry("TIME_ZONE"));
        } catch (RuntimeException e) {
            logger.error("There was an issue reading the time zone from the config file");
            return;
        }

        loadDateTimeEntries(configG, DATE_TIME_ZONE_FORMAT, dateTimeZoneFormats);
        loadDateTimeEntries(configG, DATE_TIME_OFFSET_FORMAT, dateTimeOffsetFormats);
        loadDateTimeEntries(configG, DATE_TIME_FORMAT, dateTimeFormats);
        loadDateTimeEntries(configG, DATE_FORMAT, dateFormats);
    }

    /**
     * Helper function to read the date time formats from the config file, parse them, and store them in the appropriate
     * DateTimeFormatter list for use later
     *
     * @param configG the Configurator object to load entries from
     * @param entryType the label that used in the config file for the category of format. This separates out the different
     *        formats that need to be parsed differently
     * @param dateFormats the list of DateTimeFormatter objects that corresponds to the appropriate format
     */
    private static void loadDateTimeEntries(Configurator configG, String entryType, List<DateTimeFormatter> dateFormats) {
        for (final String dateFormatEntry : configG.findEntries(entryType)) {
            try {
                DateTimeFormatter initialDtf =
                        new DateTimeFormatterBuilder().parseCaseInsensitive().appendPattern(dateFormatEntry).toFormatter();
                if (entryType.equals(DATE_TIME_ZONE_FORMAT)) {
                    initialDtf = initialDtf.withZone(zone);
                }
                final DateTimeFormatter dtf = initialDtf;
                dateFormats.add(dtf);
            } catch (RuntimeException ex) {
                logger.debug("{} entry '{}' cannot be parsed", entryType, dateFormatEntry, ex);
            }
        }
        logger.debug("Loaded {} {} entries", dateTimeZoneFormats.size(), entryType);
    }

    /**
     * Cleans up the date string by removing certain characters before attempting to parse it
     * 
     * @param dateString the date string
     * @return the cleaned date string
     */
    private static String cleanDate(String dateString) {
        // Take it apart and stick it back together to
        // get rid of multiple contiguous spaces
        String cleanedDateString = dateString.replaceAll("\t+", " "); // tabs
        cleanedDateString = cleanedDateString.replaceAll("[ ]+", " "); // multiple spaces
        cleanedDateString = cleanedDateString.replaceAll("=0D$", ""); // common qp'ified ending

        return cleanedDateString;
    }

    /**
     * Parse an RFC-822 Date or one of the thousands of variants make a quick attempt to normalize the timezone information
     * and get the timestamp in GMT. Should change to pass in a default from the U124 header
     *
     * @param dateString the string date from the RFC 822 Date header
     * @param supplyDefaultOnBad when true use current date if sentDate cannot be parsed
     * @return the GMT time of the event or NOW if it cannot be parsed, or null if supplyDefaultOnBad is false
     */
    @Nullable
    public static LocalDateTime parseDate(final String dateString, final boolean supplyDefaultOnBad) {

        if (StringUtils.isNotEmpty(dateString)) {
            String cleanedDateString = cleanDate(dateString);

            List<Function<String, LocalDateTime>> methodList = new ArrayList<>();
            methodList.add(date -> tryParseWithDateTimeZoneFormats(date));
            methodList.add(date -> tryParseWithDateTimeOffsetFormats(date));
            methodList.add(date -> tryParseWithDateTimeFormats(date));
            methodList.add(date -> tryParseWithDateFormats(date));

            for (Function<String, LocalDateTime> method : methodList) {
                LocalDateTime date = method.apply(cleanedDateString);
                if (date != null) {
                    return date;
                }
            }

            try {
                return Instant.from(DateTimeFormatter.ISO_INSTANT.parse(cleanedDateString)).atZone(zone).toLocalDateTime();
            } catch (DateTimeParseException e) {
                // ignore
            }

            // If none of these methods worked, use the default if required
            if (supplyDefaultOnBad) {
                return LocalDateTime.now(ZoneId.systemDefault());
            }
        }
        return null;
    }


    /**
     * Attempt to parse the string dateString with one of the ZonedDateTime patterns
     *
     * @param dateString the string to attempt to format
     * @return the LocalDateTime object if a formatter worked, or null otherwise
     */
    @Nullable
    private static LocalDateTime tryParseWithDateTimeZoneFormats(final String dateString) {
        // formats with a time zone
        for (final DateTimeFormatter dtf : dateTimeZoneFormats) {
            try {
                ZonedDateTime zdt = ZonedDateTime.parse(dateString, dtf);
                zdt = ZonedDateTime.ofInstant(zdt.toInstant(), zone);
                return zdt.toLocalDateTime();
            } catch (DateTimeParseException e) {
                // ignore
            }
        }
        return null;
    }

    /**
     * Attempt to parse the string dateString with one of the LocalDateTime patterns
     *
     * @param dateString the string to attempt to format
     * @return the LocalDateTime object if a formatter worked, or null otherwise
     */
    @Nullable
    private static LocalDateTime tryParseWithDateTimeFormats(final String dateString) {
        // formats with a date and time and no zone/offset
        for (final DateTimeFormatter dtf : dateTimeFormats) {
            try {
                return LocalDateTime.parse(dateString, dtf);
            } catch (DateTimeParseException e) {
                // ignore
            }
        }
        return null;
    }

    /**
     * Attempt to parse the string dateString with one of the OffsetDateTime patterns
     *
     * @param dateString the string to attempt to format
     * @return the LocalDateTime object if a formatter worked, or null otherwise
     */
    @Nullable
    private static LocalDateTime tryParseWithDateTimeOffsetFormats(final String dateString) {
        // formats with a time zone offset
        for (final DateTimeFormatter dtf : dateTimeOffsetFormats) {
            try {
                OffsetDateTime odt = OffsetDateTime.parse(dateString, dtf);
                return OffsetDateTime.ofInstant(odt.toInstant(), zone).toLocalDateTime();
            } catch (DateTimeParseException e) {
                // ignore
            }
        }
        return null;
    }

    /**
     * Attempt to parse the string dateString with one of the LocalDate patterns
     *
     * @param dateString the string to attempt to format
     * @return the LocalDateTime object if a formatter worked, or null otherwise
     */
    @Nullable
    private static LocalDateTime tryParseWithDateFormats(final String dateString) {
        // formats with a date but no time
        for (final DateTimeFormatter dtf : dateFormats) {
            try {
                LocalDate d = LocalDate.parse(dateString, dtf);
                return d.atStartOfDay();
            } catch (DateTimeParseException e) {
                // ignore
            }
        }
        return null;
    }
}