From 064455b2f7290e3917c5156573e9313dffa06245 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 25 Nov 2021 00:48:37 +0200 Subject: [PATCH] (core) Parsing pasted datetimes Summary: Add function parseDateTime which parses a string containing both date and time componenents, intended for parsing pasted strings. Add DateTimeParser subclass of ValueParser. Test Plan: Extended parseDate.ts and CopyPaste.ts tests. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D3152 --- app/common/ValueParser.ts | 10 ++-- app/common/parseDate.ts | 114 ++++++++++++++++++++++++++---------- test/nbrowser/gristUtils.ts | 16 +++++ 3 files changed, 105 insertions(+), 35 deletions(-) diff --git a/app/common/ValueParser.ts b/app/common/ValueParser.ts index fa609279..fd9eb8ed 100644 --- a/app/common/ValueParser.ts +++ b/app/common/ValueParser.ts @@ -5,7 +5,7 @@ import * as gutil from 'app/common/gutil'; import {safeJsonParse} from 'app/common/gutil'; import {getCurrency, NumberFormatOptions} from 'app/common/NumberFormat'; import NumberParse from 'app/common/NumberParse'; -import {parseDateStrict} from 'app/common/parseDate'; +import {parseDateStrict, parseDateTime} from 'app/common/parseDate'; import {DateFormatOptions, DateTimeFormatOptions, formatDecoded, FormatOptions} from 'app/common/ValueFormatter'; import flatMap = require('lodash/flatMap'); @@ -47,12 +47,16 @@ class DateParser extends ValueParser { } } -class DateTimeParser extends DateParser { +class DateTimeParser extends ValueParser { constructor(type: string, widgetOpts: DateTimeFormatOptions, docSettings: DocumentSettings) { super(type, widgetOpts, docSettings); const timezone = gutil.removePrefix(type, "DateTime:") || ''; this.widgetOpts = {...widgetOpts, timezone}; } + + public parse(value: string): any { + return parseDateTime(value, this.widgetOpts); + } } @@ -99,8 +103,6 @@ const parsers: { [type: string]: typeof ValueParser } = { ChoiceList: ChoiceListParser, }; -// TODO these are not ready yet -delete parsers.DateTime; export function createParser( type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings diff --git a/app/common/parseDate.ts b/app/common/parseDate.ts index 4b729bb8..3bcd14e2 100644 --- a/app/common/parseDate.ts +++ b/app/common/parseDate.ts @@ -55,9 +55,12 @@ const PARSER_FORMATS: string[] = [ 'D' ]; -const UNAMBIGUOUS_FORMATS = PARSER_FORMATS.filter(f => f.includes("MMM")); +const UNAMBIGUOUS_FORMATS = [ + 'YYYY M D', + ...PARSER_FORMATS.filter(f => f.includes("MMM")), +]; -const TIME_REGEX = /^(?:(\d\d?)(?::(\d\d?)(?::(\d\d?))?)?|(\d\d?)(\d\d))\s*([ap]m?)?$/i; +const TIME_REGEX = /(?:^|\s+|T)(?:(\d\d?)(?::(\d\d?)(?::(\d\d?))?)?|(\d\d?)(\d\d))\s*([ap]m?)?$/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; @@ -109,12 +112,15 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu const cleanDate = date.replace(SEPARATORS, ' '); let datetime = cleanDate.trim(); let timeformat = ''; - if (options.time) { - const standardTime = standardizeTime(options.time, options.timezone!); - if (!standardTime) { + let time = options.time; + if (time) { + const parsedTimeZone = parseTimeZone(time, options.timezone!); + const parsedTime = standardizeTime(parsedTimeZone.remaining); + if (!parsedTime || parsedTime.remaining) { return null; } - const {time, tzOffset} = standardTime; + time = parsedTime.time; + const {tzOffset} = parsedTimeZone; datetime += ' ' + time + tzOffset; timeformat = ' HH:mm:ss' + (tzOffset ? 'Z' : ''); } @@ -136,7 +142,9 @@ export function parseDate(date: string, options: ParseOptions = {}): number | nu * 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 { +export function parseDateStrict( + date: string, dateFormat: string | null, results?: Set, timezone: string = 'UTC' +): number | undefined { if (!date) { return; } @@ -147,7 +155,7 @@ export function parseDateStrict(date: string, dateFormat: string | null, results dateFormats.push(...UNAMBIGUOUS_FORMATS); const cleanDate = date.replace(SEPARATORS, ' ').trim(); for (const format of dateFormats) { - const m = moment.tz(cleanDate, format, true, 'UTC'); + const m = moment.tz(cleanDate, format, true, timezone); if (m.isValid()) { const value = m.valueOf() / 1000; if (results) { @@ -159,6 +167,45 @@ export function parseDateStrict(date: string, dateFormat: string | null, results } } +export function parseDateTime(dateTime: string, options: ParseOptions): number | undefined { + dateTime = dateTime.trim(); + if (!dateTime) { + return; + } + + const dateFormat = options.dateFormat || null; + const timezone = options.timezone || "UTC"; + + const dateOnly = parseDateStrict(dateTime, dateFormat, undefined, timezone); + if (dateOnly) { + return dateOnly; + } + + const parsedTimeZone = parseTimeZone(dateTime, timezone); + let tzOffset = ''; + if (parsedTimeZone) { + tzOffset = parsedTimeZone.tzOffset; + dateTime = parsedTimeZone.remaining; + } + + const parsedTime = standardizeTime(dateTime); + if (!parsedTime) { + return; + } + + dateTime = parsedTime.remaining; + const date = parseDateStrict(dateTime, dateFormat); + + if (!date) { + return; + } + + const dateString = moment.unix(date).format("YYYY-MM-DD"); + dateTime = dateString + ' ' + parsedTime.time + tzOffset; + const fullFormat = "YYYY-MM-DD HH:mm:ss" + (tzOffset ? 'Z' : ''); + return moment.tz(dateTime, fullFormat, true, timezone).valueOf() / 1000; +} + // 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 @@ -228,26 +275,23 @@ function calculateOffset(tzMatch: string[]): string { 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, timezone: string): { time: string, tzOffset: string } | undefined { - let cleanTime = timeString.trim(); +function parseTimeZone(str: string, timezone: string): { remaining: string, tzOffset: string } { + str = str.trim(); - let tzMatch = UTC_REGEX.exec(cleanTime); + let tzMatch = UTC_REGEX.exec(str); let matchStart = 0; let tzOffset = ''; if (tzMatch) { tzOffset = '+00:00'; matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start } else { - tzMatch = NUMERIC_TZ_REGEX.exec(cleanTime); + tzMatch = NUMERIC_TZ_REGEX.exec(str); 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); + tzMatch = tzAbbreviations(timezone).exec(str); if (tzMatch) { matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start } @@ -255,21 +299,29 @@ function standardizeTime(timeString: string, timezone: string): { time: string, } if (tzMatch) { - cleanTime = cleanTime.slice(0, matchStart).trim(); + str = str.slice(0, matchStart).trim(); } - const match = TIME_REGEX.exec(cleanTime); - if (match) { - let hours = parseInt(match[1] || match[4], 10); - const mm = (match[2] || match[5] || '0').padStart(2, '0'); - const ss = (match[3] || '0').padStart(2, '0'); - const ampm = (match[6] || '').toLowerCase(); - if (hours < 12 && hours > 0 && ampm.startsWith('p')) { - hours += 12; - } else if (hours === 12 && ampm.startsWith('a')) { - hours = 0; - } - const hh = String(hours).padStart(2, '0'); - return {time: `${hh}:${mm}:${ss}`, tzOffset}; - } + return {remaining: str, tzOffset}; +} + +// Parses time of the form, roughly, HH[:MM[:SS]][am|pm]. Returns the time in the +// standardized HH:mm:ss format. +// This turns out easier than coaxing moment to parse time sensibly and flexibly. +function standardizeTime(timeString: string): { remaining: string, time: string } | undefined { + const match = TIME_REGEX.exec(timeString); + if (!match) { + return; + } + let hours = parseInt(match[1] || match[4], 10); + const mm = (match[2] || match[5] || '0').padStart(2, '0'); + const ss = (match[3] || '0').padStart(2, '0'); + const ampm = (match[6] || '').toLowerCase(); + if (hours < 12 && hours > 0 && ampm.startsWith('p')) { + hours += 12; + } else if (hours === 12 && ampm.startsWith('a')) { + hours = 0; + } + const hh = String(hours).padStart(2, '0'); + return {remaining: timeString.slice(0, match.index).trim(), time: `${hh}:${mm}:${ss}`}; } diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 16f76a54..5726b246 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1714,6 +1714,22 @@ export async function setDateFormat(format: string) { await waitForServer(); } +/** + * Returns time format for datetime editor + */ +export async function getTimeFormat(): Promise { + return driver.find('[data-test-id=Widget_timeFormat] .test-select-row').getText(); +} + +/** + * Changes time format for datetime editor + */ +export async function setTimeFormat(format: string) { + await driver.find('[data-test-id=Widget_timeFormat]').click(); + await driver.findContent('.test-select-menu .test-select-row', format).click(); + await waitForServer(); +} + /** * Returns "Show column" setting value of a reference column. */