diff --git a/app/common/parseDate.ts b/app/common/parseDate.ts index 6ab9d62c..4b729bb8 100644 --- a/app/common/parseDate.ts +++ b/app/common/parseDate.ts @@ -1,3 +1,5 @@ +import escapeRegExp = require('lodash/escapeRegExp'); +import memoize = require('lodash/memoize'); import * as moment from 'moment-timezone'; // When using YY format, use a consistent interpretation in datepicker and in moment parsing: add @@ -55,14 +57,26 @@ const PARSER_FORMATS: string[] = [ const UNAMBIGUOUS_FORMATS = PARSER_FORMATS.filter(f => f.includes("MMM")); -// The TZ portion is based on moment's RFC2822 regex, supporting US time zones, and UT. See -// https://momentjs.com/docs/#/parsing/string/ const TIME_REGEX = /^(?:(\d\d?)(?::(\d\d?)(?::(\d\d?))?)?|(\d\d?)(\d\d))\s*([ap]m?)?$/i; -const TZ_REGEX = /\s*(UTC?|GMT|[ECMP][SD]T|Z)|(?:([+-]\d\d?)(?::?(\d\d))?)$/i; +// [^a-zA-Z] because no letters are allowed directly before the abbreviation +const UTC_REGEX = /[^a-zA-Z](UTC?|GMT|Z)$/i; +const NUMERIC_TZ_REGEX = /([+-]\d\d?)(?::?(\d\d))?$/i; // Not picky about separators, so replace them in the date and format strings to be spaces. const SEPARATORS = /[\W_]+/g; +const tzAbbreviations = memoize((tzName: string): RegExp => { + // Some abbreviations are just e.g. +05 + // and escaping the + seems better than filtering + const abbreviations = new Set(moment.tz.zone(tzName)!.abbrs.map(escapeRegExp)); + + const union = [...abbreviations].join('|'); + + // [^a-zA-Z] because no letters are allowed directly before the abbreviation + // so for example CEST won't match even if EST does + return new RegExp(`[^a-zA-Z](${union})$`, 'i'); +}); + interface ParseOptions { time?: string; dateFormat?: string; @@ -96,7 +110,11 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu let datetime = cleanDate.trim(); let timeformat = ''; if (options.time) { - const {time, tzOffset} = standardizeTime(options.time); + const standardTime = standardizeTime(options.time, options.timezone!); + if (!standardTime) { + return null; + } + const {time, tzOffset} = standardTime; datetime += ' ' + time + tzOffset; timeformat = ' HH:mm:ss' + (tzOffset ? 'Z' : ''); } @@ -202,41 +220,44 @@ function _buildVariations(dateFormat: string, date: string) { return variations; } -// This is based on private obsOffset in moment source code. -const tzOffsets: {[name: string]: string} = { - EDT: '-04:00', - EST: '-05:00', - CDT: '-05:00', - CST: '-06:00', - MDT: '-06:00', - MST: '-07:00', - PDT: '-07:00', - PST: '-08:00', -}; // Based on private calculateOffset in moment source code. function calculateOffset(tzMatch: string[]): string { - const [, tzName, hhOffset, mmOffset] = tzMatch; - if (tzName) { - // Zero offsets like Z, UT[C], GMT are captured by the fallback. - return tzOffsets[tzName.toUpperCase()] || '+00:00'; - } else { - const sign = hhOffset.slice(0, 1); - return sign + hhOffset.slice(1).padStart(2, '0') + ':' + (mmOffset || '0').padStart(2, '0'); - } + const [, hhOffset, mmOffset] = tzMatch; + const sign = hhOffset.slice(0, 1); + return sign + hhOffset.slice(1).padStart(2, '0') + ':' + (mmOffset || '0').padStart(2, '0'); } // Parses time of the form, roughly, HH[:MM[:SS]][am|pm] [TZ]. Returns the time in the // standardized HH:mm:ss format, and an offset string that's empty or is of the form [+-]HH:mm. // This turns out easier than coaxing moment to parse time sensibly and flexibly. -function standardizeTime(timeString: string): {time: string, tzOffset: string} { +function standardizeTime(timeString: string, timezone: string): { time: string, tzOffset: string } | undefined { let cleanTime = timeString.trim(); - const tzMatch = TZ_REGEX.exec(cleanTime); + + let tzMatch = UTC_REGEX.exec(cleanTime); + let matchStart = 0; let tzOffset = ''; if (tzMatch) { - cleanTime = cleanTime.slice(0, tzMatch.index).trim(); - tzOffset = calculateOffset(tzMatch); + tzOffset = '+00:00'; + matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start + } else { + tzMatch = NUMERIC_TZ_REGEX.exec(cleanTime); + if (tzMatch) { + tzOffset = calculateOffset(tzMatch); + matchStart = tzMatch.index; + } else if (timezone) { + // Abbreviations are simply stripped and ignored, so tzOffset is not set in this case + tzMatch = tzAbbreviations(timezone).exec(cleanTime); + if (tzMatch) { + matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start + } + } } + + if (tzMatch) { + cleanTime = cleanTime.slice(0, matchStart).trim(); + } + const match = TIME_REGEX.exec(cleanTime); if (match) { let hours = parseInt(match[1] || match[4], 10); @@ -250,7 +271,5 @@ function standardizeTime(timeString: string): {time: string, tzOffset: string} { } const hh = String(hours).padStart(2, '0'); return {time: `${hh}:${mm}:${ss}`, tzOffset}; - } else { - return {time: '00:00:00', tzOffset}; } }