(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
This commit is contained in:
Alex Hall 2021-11-25 00:48:37 +02:00
parent e997d091b3
commit 064455b2f7
3 changed files with 105 additions and 35 deletions

View File

@ -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

View File

@ -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>): number | undefined {
export function parseDateStrict(
date: string, dateFormat: string | null, results?: Set<number>, 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}`};
}

View File

@ -1714,6 +1714,22 @@ export async function setDateFormat(format: string) {
await waitForServer();
}
/**
* Returns time format for datetime editor
*/
export async function getTimeFormat(): Promise<string> {
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.
*/