(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
pull/115/head
Alex Hall 3 years ago
parent e997d091b3
commit 064455b2f7

@ -5,7 +5,7 @@ import * as gutil from 'app/common/gutil';
import {safeJsonParse} from 'app/common/gutil'; import {safeJsonParse} from 'app/common/gutil';
import {getCurrency, NumberFormatOptions} from 'app/common/NumberFormat'; import {getCurrency, NumberFormatOptions} from 'app/common/NumberFormat';
import NumberParse from 'app/common/NumberParse'; 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 {DateFormatOptions, DateTimeFormatOptions, formatDecoded, FormatOptions} from 'app/common/ValueFormatter';
import flatMap = require('lodash/flatMap'); 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) { constructor(type: string, widgetOpts: DateTimeFormatOptions, docSettings: DocumentSettings) {
super(type, widgetOpts, docSettings); super(type, widgetOpts, docSettings);
const timezone = gutil.removePrefix(type, "DateTime:") || ''; const timezone = gutil.removePrefix(type, "DateTime:") || '';
this.widgetOpts = {...widgetOpts, timezone}; 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, ChoiceList: ChoiceListParser,
}; };
// TODO these are not ready yet
delete parsers.DateTime;
export function createParser( export function createParser(
type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings

@ -55,9 +55,12 @@ const PARSER_FORMATS: string[] = [
'D' '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 // [^a-zA-Z] because no letters are allowed directly before the abbreviation
const UTC_REGEX = /[^a-zA-Z](UTC?|GMT|Z)$/i; const UTC_REGEX = /[^a-zA-Z](UTC?|GMT|Z)$/i;
const NUMERIC_TZ_REGEX = /([+-]\d\d?)(?::?(\d\d))?$/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, ' '); const cleanDate = date.replace(SEPARATORS, ' ');
let datetime = cleanDate.trim(); let datetime = cleanDate.trim();
let timeformat = ''; let timeformat = '';
if (options.time) { let time = options.time;
const standardTime = standardizeTime(options.time, options.timezone!); if (time) {
if (!standardTime) { const parsedTimeZone = parseTimeZone(time, options.timezone!);
const parsedTime = standardizeTime(parsedTimeZone.remaining);
if (!parsedTime || parsedTime.remaining) {
return null; return null;
} }
const {time, tzOffset} = standardTime; time = parsedTime.time;
const {tzOffset} = parsedTimeZone;
datetime += ' ' + time + tzOffset; datetime += ' ' + time + tzOffset;
timeformat = ' HH:mm:ss' + (tzOffset ? 'Z' : ''); 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 * 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. * 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) { if (!date) {
return; return;
} }
@ -147,7 +155,7 @@ export function parseDateStrict(date: string, dateFormat: string | null, results
dateFormats.push(...UNAMBIGUOUS_FORMATS); dateFormats.push(...UNAMBIGUOUS_FORMATS);
const cleanDate = date.replace(SEPARATORS, ' ').trim(); const cleanDate = date.replace(SEPARATORS, ' ').trim();
for (const format of dateFormats) { for (const format of dateFormats) {
const m = moment.tz(cleanDate, format, true, 'UTC'); const m = moment.tz(cleanDate, format, true, timezone);
if (m.isValid()) { if (m.isValid()) {
const value = m.valueOf() / 1000; const value = m.valueOf() / 1000;
if (results) { 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 // 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 // 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'); 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 function parseTimeZone(str: string, timezone: string): { remaining: string, tzOffset: string } {
// standardized HH:mm:ss format, and an offset string that's empty or is of the form [+-]HH:mm. str = str.trim();
// 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();
let tzMatch = UTC_REGEX.exec(cleanTime); let tzMatch = UTC_REGEX.exec(str);
let matchStart = 0; let matchStart = 0;
let tzOffset = ''; let tzOffset = '';
if (tzMatch) { if (tzMatch) {
tzOffset = '+00:00'; tzOffset = '+00:00';
matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start
} else { } else {
tzMatch = NUMERIC_TZ_REGEX.exec(cleanTime); tzMatch = NUMERIC_TZ_REGEX.exec(str);
if (tzMatch) { if (tzMatch) {
tzOffset = calculateOffset(tzMatch); tzOffset = calculateOffset(tzMatch);
matchStart = tzMatch.index; matchStart = tzMatch.index;
} else if (timezone) { } else if (timezone) {
// Abbreviations are simply stripped and ignored, so tzOffset is not set in this case // 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) { if (tzMatch) {
matchStart = tzMatch.index + 1; // skip [^a-zA-Z] at regex start 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) { if (tzMatch) {
cleanTime = cleanTime.slice(0, matchStart).trim(); str = str.slice(0, matchStart).trim();
} }
const match = TIME_REGEX.exec(cleanTime); return {remaining: str, tzOffset};
if (match) { }
let hours = parseInt(match[1] || match[4], 10);
const mm = (match[2] || match[5] || '0').padStart(2, '0'); // Parses time of the form, roughly, HH[:MM[:SS]][am|pm]. Returns the time in the
const ss = (match[3] || '0').padStart(2, '0'); // standardized HH:mm:ss format.
const ampm = (match[6] || '').toLowerCase(); // This turns out easier than coaxing moment to parse time sensibly and flexibly.
if (hours < 12 && hours > 0 && ampm.startsWith('p')) { function standardizeTime(timeString: string): { remaining: string, time: string } | undefined {
hours += 12; const match = TIME_REGEX.exec(timeString);
} else if (hours === 12 && ampm.startsWith('a')) { if (!match) {
hours = 0; return;
} }
const hh = String(hours).padStart(2, '0'); let hours = parseInt(match[1] || match[4], 10);
return {time: `${hh}:${mm}:${ss}`, tzOffset}; 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}`};
} }

@ -1714,6 +1714,22 @@ export async function setDateFormat(format: string) {
await waitForServer(); 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. * Returns "Show column" setting value of a reference column.
*/ */

Loading…
Cancel
Save