parse.js

/**
 * Date and time parsing
 *
 * @module datetimejs.parse
 */

import {lower, cycle, zeroPad, h12to24} from './utils';
import {DEFAULT_CONFIG} from './config';

// Array of regexp characters that should be escaped in a format string when
// parsing dates and times.
const REGEXP_CHARS = [
  '.',
  '^', '$',
  '[', ']',
  '(', ')',
  '{', '}',
  '+', '*', '?',
  '|',
];

/**
 * Recipes for parsing the date
 *
 * A recipe consists of a regular expression fragment (string) and a parsing
 * function. This object maps parse tokens to factory functions that take a
 * format configuration object and returns a recipe.
 *
 * The recipes are represented by an object with `re` and `fn` keys. The `re`
 * key is a string that represents a regular expression fragment that will
 * match any valid data for the token. The `fn` function takes the string
 * matched by the regular expression fragment, and an object that tracks the
 * progress of various values that were parsed. The function should examine the
 * string and update the progress object.
 *
 * To add a recipe for a new token, or override a recipe for an existing one,
 * we simply assign to the corresponding key on the `parse.PARSE_RECIPES`
 * object.
 *
 * @example
 *
 * parse.PARSE_RECIPES['%R'] = function (conf) {
 *   return {
 *     re: '....',
 *     fn: function (s, parsedValues) {
 *       // do something with `s` and update `parsedValues`
 *     },
 *   };
 * };
 */
export let PARSE_RECIPES = {
  '%b': function (conf) {
    let lowerCaseMonths = conf.MNTH.map(lower);

    return {
      re: conf.MNTH.join('|'),
      fn: function (s, parsedValues) {
        parsedValues.month = lowerCaseMonths.indexOf(s.toLowerCase());
      },
    };
  },
  '%B': function (conf) {
    let lowerCaseMonths = conf.MONTHS.map(lower);

    return {
      re: conf.MONTHS.join('|'),
      fn: function (s, parsedValues) {
        parsedValues.month = lowerCaseMonths.indexOf(s.toLowerCase());
      },
    };
  },
  '%d': function () {
    return {
      re: '[0-2][0-9]|3[01]',
      fn: function (s, parsedValues) {
        parsedValues.date = parseInt(s, 10);
      },
    };
  },
  '%D': function () {
    return {
      re: '3[01]|[12]?\\d',
      fn: function (s, parsedValues) {
        parsedValues.date = parseInt(s, 10);
      },
    };
  },
  '%f': function () {
    return {
      re: '\\d{2}\\.\\d{2}',
      fn: function (s, parsedValues) {
        let i = parseInt(s, 10);
        let f = parseFloat(s);
        parsedValues.second = i;
        parsedValues.millisecond = f * 1000 % 1000;
      },
    };
  },
  '%H': function () {
    return {
      re: '[0-1]\\d|2[0-3]',
      fn: function (s, parsedValues) {
        parsedValues.hour = parseInt(s, 10);
      },
    };
  },
  '%i': function () {
    return {
      re: '1[0-2]|\\d',
      fn: function (s, parsedValues) {
        parsedValues.hour = parseInt(s, 10);
      },
    };
  },
  '%I': function () {
    return {
      re: '0\\d|1[0-2]',
      fn: function (s, parsedValues) {
        parsedValues.hour = parseInt(s, 10);
      },
    };
  },
  '%m': function () {
    return {
      re: '0\\d|1[0-2]',
      fn: function (s, parsedValues) {
        parsedValues.month = parseInt(s, 10) - 1;
      },
    };
  },
  '%M': function () {
    return {
      re: '[0-5]\\d',
      fn: function (s, parsedValues) {
        parsedValues.minute = parseInt(s, 10);
      },
    };
  },
  '%n': function () {
    return {
      re: '1[0-2]|\\d',
      fn: function (s, parsedValues) {
        parsedValues.month = parseInt(s, 10) - 1;
      },
    };
  },
  '%N': function () {
    return {
      re: '[1-5]?\\d',
      fn: function (s, parsedValues) {
        parsedValues.minute = parseInt(s, 10);
      },
    };
  },
  '%p': function (conf) {
    return {
      re: `${conf.PM.replace(/\./g, '\\.')}|${conf.AM.replace(/\./g, '\\.')}`,
      fn: function (s, parsedValues) {
        parsedValues.isPMin12h = conf.PM.toLowerCase() === s.toLowerCase();
      },
    };
  },
  '%s': function () {
    return {
      re: '[1-5]?\\d',
      fn: function (s, parsedValues) {
        parsedValues.second = parseInt(s, 10);
      },
    };
  },
  '%S': function () {
    return {
      re: '[0-5]\\d',
      fn: function (s, parsedValues) {
        parsedValues.second = parseInt(s, 10);
      },
    };
  },
  '%r': function () {
    return {
      re: '\\d{1,3}',
      fn: function (s, parsedValues) {
        parsedValues.millisecond = parseInt(s, 10);
      },
    };
  },
  '%y': function () {
    let thisYear = (new Date()).getFullYear();

    return {
      re: '\\d{2}',
      fn: function (s, parsedValues) {
        let c = thisYear.toString().slice(0, 2);
        parsedValues.year = parseInt(c + s, 10);
      },
    };
  },
  '%Y': function () {
    return {
      re: '\\d{4}',
      fn: function (s, parsedValues) {
        parsedValues.year = parseInt(s, 10);
      },
    };
  },
  '%z': function () {
    return {
      re: '[+-](?1[01]|0\\d)[0-5]\\d|Z',
      fn: function (s, parsedValues) {
        if (s === 'Z') {
          parsedValues.timezone = 0
        }
        else {
          let mult = s.startsWith('-') ? 1 : -1;
          let h = parseInt(s.slice(0, 2), 10);
          let m = parseInt(s.slice(2, 2), 10);
          parsedValues.timezone = mult * (h * 60) + m;
        }
      },
    };
  },
};

/**
 * Parse a date/time string according to a format string
 *
 * The return value is a `Date` object or `null` if the input does not match
 * the format string.
 *
 * The `Date` object is always in the local time zone, but if time zone offset
 * appears in the string, it will be taken into account and the resulting
 * object adjusted accordingly.
 *
 * The format string is an arbitrary string that contains format sequences
 *
 * - `%b` - Short month name (e.g., 'Jan', 'Feb'...).
 * - `%B` - Full month name (e.g., 'January', 'February'...).
 * - `%d` - Zero-padded date (e.g, 02, 31...).
 * - `%D` - Non-zero-padded date (e.g., 2, 31...).
 * - `%H` - Zero-padded hour in 24-hour format (e.g., 8, 13, 0...).
 * - `%i` - Non-zero-padded hour in 12-hour format (e.g., 8, 1, 12...).
 * - `%I` - Zero-padded hour in 12-hour format (e.g., 08, 01, 12...).
 * - `%m` - Zero-padded month (e.g., 01, 02...).
 * - `%M` - Zero-padded minutes (e.g., 01, 12, 59...).
 * - `%n` - Non-zero-padded month (e.g., 1, 2...).
 * - `%N` - Non-zero-padded minutes (e.g., 1, 12, 59).
 * - `%p` - AM/PM (a.m. and p.m.).
 * - `%s` - Non-zero-padded seconds (e.g., 1, 2, 50...).
 * - `%S` - Zero-padded seconds (e.g., 01, 02, 50...).
 * - `%r` - Milliseconds (e.g., 1, 24, 500...).
 * - `%y` - Zero-padded year without the century part (e.g., 01, 13, 99...).
 * - `%Y` - Full year (e.g., 2001, 2013, 2099...).
 * - `%z` - UTC offset in +HHMM or -HHMM format or 'Z' (e.g., +1000, -0200).
 * - `%%` - literal percent character.
 *
 * The `%z` token behaves slightly differently when parsing date and time
 * strings. In addition to formats that strftime outputs, it also supports
 * 'Z', which allows parsing of ISO timestamps.
 *
 * @example
 *
 * let s1 = '2019 Jan 12 4:55 p.m.';
 * let d1 = parse.strptime(s, '%Y %b %D %i:%M %p');
 * // => new Date(2019, 0, 12, 16, 55)
 *
 * // With customized AM/PM
 * let s2 = '18/March/2019 07:22am';
 * let d2 = parse.strptime(s, '%d/%B/%Y %I:%M%p', {
 *   AM: 'am',
 *   PM: 'pm',
 * });
 * // => new Date(2019, 2, 18, 7, 22)
 *
 * @param {string} s Formatted date/time string
 * @param {string} formatString The expected format of the `s` argument
 * @param {object} [config=DEFAULT_CONFIG] Format configuration
 */
export function strptime(s, formatString, config = DEFAULT_CONFIG) {
  // Build the regexp to match format tokens inside format string
  let parseTokens = Object.keys(PARSE_RECIPES).join('|');
  let parseTokenRe = new RegExp(`(${parseTokens}|%%)`, 'g');

  // Prepare the format string for matching
  let rxp = formatString.replace(/\\/, '\\\\');
  REGEXP_CHARS.forEach(function (schr) {
    rxp = rxp.replace(new RegExp('\\' + schr, 'g'), `\\${schr}`);
  });

  // Replace all format tokens inside the format string with the actual regexp
  // that matches each token, and build a regexp string.
  let converters = [];
  rxp = rxp.replace(parseTokenRe, function (_, token) {
    if (token === '%%') {
      return '%';
    }

    // Get the token regexp and parser function
    let {re, fn} = PARSE_RECIPES[token](config);
    converters.push(fn);
    return `(${re})`;
  });

  // Convert the regexp string to a `RegeExp` object.
  rxp = new RegExp(`^${rxp}$`, 'i');

  // Perform the match
  let matches = rxp.exec(s);

  // We consider the parse failed if nothing matched
  if (!matches) {
    return null;
  }

  // Remove the first item from the matches, since we're not interested in it
  matches.shift()

  // The object used to keep track of the parsing
  let parsedValues = {
    year: 0,
    month: 0,
    date: 1,
    hour: 0,
    minute: 0,
    second: 0,
    millisecond: 0,
    isPMin12h: null,
    timezone: null,
  };

  // Iterate parser functions and apply the function to each match
  matches.forEach(function (match, idx) {
    let fn = converters[idx];
    fn(match, parsedValues);
  });

  if (parsedValues.isPMin12h) {
    parsedValues.hour = h12to24(parsedValues.hour, parsedValues.isPMin12h);
  }

  // Construct the `Date` object using the parsedValues object
  let dt = new Date(
    parsedValues.year,
    parsedValues.month,
    parsedValues.date,
    parsedValues.hour,
    parsedValues.minute,
    parsedValues.second,
    parsedValues.millisecond
  );

  if (parsedValues.timezone != null) {
    // Determine the relative offset of the original time to local time of
    // the platform.
    let localOffset = dt.getTimezoneOffset();
    // Shift the time by the difference of timezone and local zone
    let offset = localOffset - parsedValues.timezone
    dt.setMinutes(d.getMinutes() + offset);
  }

  return dt;
};