(core) Change timezone abbreviation parsing

Summary: Allows any timezone abbreviation associated with the given timezone, and simply ignores it. Previously only certain abbreviations worked and they were not unique so using them outside the US was broken.

Test Plan: Added parseDate tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3106
This commit is contained in:
Alex Hall 2021-11-03 15:46:59 +02:00
parent 3c72639e25
commit 1db138d7ac

View File

@ -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 [, 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 = '+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};
}
}