From e58df5df5bc22ebf23929d7607fde2832875c1f8 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Mon, 25 Oct 2021 19:42:06 +0200 Subject: [PATCH] (core) ValueParser for Date columns Summary: Adds parseDateStrict function based on parseDate, uses it in DateParser subclass of ValueParser. Test Plan: Tweaked parseDate test to check parseDateStrict. Extended test in CopyPaste to test parsing dates as well as numbers. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D3088 --- app/common/ValueParser.ts | 7 ++-- app/common/parseDate.ts | 80 +++++++++++++++++++++++++++++++------ test/nbrowser/gristUtils.ts | 16 ++++++++ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/app/common/ValueParser.ts b/app/common/ValueParser.ts index 2b2eeb3a..9001773c 100644 --- a/app/common/ValueParser.ts +++ b/app/common/ValueParser.ts @@ -3,8 +3,8 @@ import * as gristTypes from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; import { getCurrency, NumberFormatOptions } from 'app/common/NumberFormat'; import NumberParse from 'app/common/NumberParse'; -import { parseDate } from 'app/common/parseDate'; -import { DateTimeFormatOptions, FormatOptions } from 'app/common/ValueFormatter'; +import { parseDateStrict } from 'app/common/parseDate'; +import { DateFormatOptions, DateTimeFormatOptions, FormatOptions } from 'app/common/ValueFormatter'; export class ValueParser { @@ -40,7 +40,7 @@ export class NumericParser extends ValueParser { class DateParser extends ValueParser { public parse(value: string): any { - return parseDate(value, this.widgetOpts); + return parseDateStrict(value, (this.widgetOpts as DateFormatOptions).dateFormat!); } } @@ -60,7 +60,6 @@ const parsers: { [type: string]: typeof ValueParser } = { }; // TODO these are not ready yet -delete parsers.Date; delete parsers.DateTime; export function createParser( diff --git a/app/common/parseDate.ts b/app/common/parseDate.ts index b75b9c79..6ab9d62c 100644 --- a/app/common/parseDate.ts +++ b/app/common/parseDate.ts @@ -30,11 +30,19 @@ const PARSER_FORMATS: string[] = [ 'MMMM D', 'MMMM Do YYYY', 'MMMM Do', + 'D MMMM YYYY', + 'D MMMM', + 'Do MMMM YYYY', + 'Do MMMM', 'MMMM', 'MMM D YYYY', 'MMM D', + 'MMM Do YYYY', + 'MMM Do', 'D MMM YYYY', 'D MMM', + 'Do MMM YYYY', + 'Do MMM', 'MMM', 'YYYY M D', 'YYYY M', @@ -45,11 +53,16 @@ const PARSER_FORMATS: string[] = [ 'D' ]; +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; +// Not picky about separators, so replace them in the date and format strings to be spaces. +const SEPARATORS = /[\W_]+/g; + interface ParseOptions { time?: string; dateFormat?: string; @@ -74,22 +87,12 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu if (!date) { return null; } - // Not picky about separators, so replace them in the date and format strings to be spaces. - const separators = /[\W_]+/g; const dateFormats = PARSER_FORMATS.slice(); // If a preferred parse format is given, set that to be the first parser used. if (options.dateFormat) { - // Momentjs has an undesirable feature in strict mode where MM and DD - // matches require two digit numbers. Change MM, DD to M, D. - let format = options.dateFormat.replace(/MM+/g, m => (m === 'MM' ? 'M' : m)) - .replace(/DD+/g, m => (m === 'DD' ? 'D' : m)) - .replace(separators, ' '); - format = _getPartialFormat(date, format); - // Consider some alternatives to the preferred format. - const variations = _buildVariations(format); - dateFormats.unshift(...variations); + dateFormats.unshift(..._buildVariations(options.dateFormat, date)); } - const cleanDate = date.replace(separators, ' '); + const cleanDate = date.replace(SEPARATORS, ' '); let datetime = cleanDate.trim(); let timeformat = ''; if (options.time) { @@ -107,6 +110,38 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu return null; } +/** + * Similar to parseDate, with these differences: + * - Only for a date (no time part) + * - Only falls back to UNAMBIGUOUS_FORMATS, not the full PARSER_FORMATS + * - Optionally adds all dates which match some format to `results`, otherwise returns first match. + * This is safer so it can be used for parsing when pasting a large number of dates + * and won't silently swap around day and month. + */ +export function parseDateStrict(date: string, dateFormat: string | null, results?: Set): number | undefined { + if (!date) { + return; + } + const dateFormats = []; + if (dateFormat) { + dateFormats.push(..._buildVariations(dateFormat, date)); + } + dateFormats.push(...UNAMBIGUOUS_FORMATS); + const cleanDate = date.replace(SEPARATORS, ' ').trim(); + for (const format of dateFormats) { + const m = moment.tz(cleanDate, format, true, 'UTC'); + if (m.isValid()) { + const value = m.valueOf() / 1000; + if (results) { + results.add(value); + } else { + return value; + } + } + } +} + + // Helper function to get the partial format string based on the input. Momentjs has a feature // which allows defaulting to the current year, month and/or day if not accounted for in the // parser. We remove any parts of the parser not given in the input to take advantage of this @@ -137,7 +172,26 @@ function _getPartialFormat(input: string, format: string): string { // Moment non-strict mode is considered bad, as it's far too lax. But moment's strict mode is too // strict. We want to allow YY|YYYY for either year specifier, as well as M for MMM or MMMM month // specifiers. It's silly that we need to create multiple format variations to support this. -function _buildVariations(format: string) { +function _buildVariations(dateFormat: string, date: string) { + // Momentjs has an undesirable feature in strict mode where MM and DD + // matches require two digit numbers. Change MM, DD to M, D. + let format = dateFormat.replace(/MM+/g, m => (m === 'MM' ? 'M' : m)) + .replace(/DD+/g, m => (m === 'DD' ? 'D' : m)) + .replace(SEPARATORS, ' ') + .trim(); + + // Allow the input date to end with a 4-digit year even if the format doesn't mention the year + if ( + format.includes("M") && + format.includes("D") && + !format.includes("Y") + ) { + format += " YYYY"; + } + + format = _getPartialFormat(date, format); + + // Consider some alternatives to the preferred format. const variations = new Set([format]); const otherYear = format.replace(/Y{2,4}/, (m) => (m === 'YY' ? 'YYYY' : (m === 'YYYY' ? 'YY' : m))); variations.add(otherYear); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index aa72b1bb..522d07f8 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1695,6 +1695,22 @@ export async function openDocumentSettings() { await driver.findWait('.test-modal-title', 5000); } +/** + * Returns date format for date and datetime editor + */ +export async function getDateFormat(): Promise { + return driver.find('[data-test-id=Widget_dateFormat] .test-select-row').getText(); +} + +/** + * Changes date format for date and datetime editor + */ +export async function setDateFormat(format: string) { + await driver.find('[data-test-id=Widget_dateFormat]').click(); + await driver.findContent('.test-select-menu .test-select-row', format).click(); + await waitForServer(); +} + } // end of namespace gristUtils stackWrapOwnMethods(gristUtils);