From 0d460ac2d4afd412838ef832bc5f47bcd0be63cd Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 19 Nov 2021 20:54:42 +0200 Subject: [PATCH] (core) Parsing pasted ChoiceLists Summary: Added ChoiceListParser capable of parsing JSON, CSVs, and other strings containing user-configured choices (e.g. separated by spaces) I got a little carried away here. It works, and I can't think of any bugs, but it's complicated enough that there could be hidden edge cases or difficulties maintaining it in the future. The advantage of the current method is that it should work well for ambiguous or poorly formatted inputs, e.g. choices separated only by spaces or choices containing commas which are not escaped/quoted properly. The code can be vastly simplified if we don't try to support that and require that users paste proper JSON or CSVs. Test Plan: Added a new file test/common/ChoiceListParser.ts with pure unit tests. Waiting for approval of the overall approach before adding to the nbrowser CopyPaste test. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D3141 --- app/client/widgets/ChoiceListEntry.ts | 2 ++ app/common/ValueFormatter.ts | 8 ++--- app/common/ValueParser.ts | 48 ++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/client/widgets/ChoiceListEntry.ts b/app/client/widgets/ChoiceListEntry.ts index c3a1a248..1ec74195 100644 --- a/app/client/widgets/ChoiceListEntry.ts +++ b/app/client/widgets/ChoiceListEntry.ts @@ -242,6 +242,8 @@ export class ChoiceListEntry extends Disposable { const rename = async (to: string) => { const tokenField = this._tokenFieldHolder.get(); if (!tokenField) { return; } + + to = to.trim(); // If user removed the label, revert back to original one. if (!to) { choiceText.set(token.label); diff --git a/app/common/ValueFormatter.ts b/app/common/ValueFormatter.ts index 3a42c9fe..16338861 100644 --- a/app/common/ValueFormatter.ts +++ b/app/common/ValueFormatter.ts @@ -19,7 +19,7 @@ export interface FormatOptions { * Formats a value of any type generically (with no type-specific options). */ export function formatUnknown(value: CellValue): string { - return formatHelper(decodeObject(value)); + return formatDecoded(decodeObject(value)); } /** @@ -27,13 +27,13 @@ export function formatUnknown(value: CellValue): string { * like to see them in a cell or in, say, CSV export. For lists and objects, nested values are * formatted slighly differently, with quoted strings and ISO format for dates. */ -function formatHelper(value: unknown, isTopLevel: boolean = true): string { +export function formatDecoded(value: unknown, isTopLevel: boolean = true): string { if (typeof value === 'object' && value) { if (Array.isArray(value)) { - return '[' + value.map(v => formatHelper(v, false)).join(', ') + ']'; + return '[' + value.map(v => formatDecoded(v, false)).join(', ') + ']'; } else if (isPlainObject(value)) { const obj: any = value; - const items = Object.keys(obj).map(k => `${JSON.stringify(k)}: ${formatHelper(obj[k], false)}`); + const items = Object.keys(obj).map(k => `${JSON.stringify(k)}: ${formatDecoded(obj[k], false)}`); return '{' + items.join(', ') + '}'; } else if (isTopLevel && value instanceof GristDateTime) { return moment(value).tz(value.timezone).format("YYYY-MM-DD HH:mm:ssZ"); diff --git a/app/common/ValueParser.ts b/app/common/ValueParser.ts index 6ce641c7..fa609279 100644 --- a/app/common/ValueParser.ts +++ b/app/common/ValueParser.ts @@ -1,10 +1,13 @@ -import { DocumentSettings } from 'app/common/DocumentSettings'; +import {csvDecodeRow} from 'app/common/csvFormat'; +import {DocumentSettings} from 'app/common/DocumentSettings'; import * as gristTypes from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; -import { getCurrency, NumberFormatOptions } from 'app/common/NumberFormat'; +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 { DateFormatOptions, DateTimeFormatOptions, FormatOptions } from 'app/common/ValueFormatter'; +import {parseDateStrict} from 'app/common/parseDate'; +import {DateFormatOptions, DateTimeFormatOptions, formatDecoded, FormatOptions} from 'app/common/ValueFormatter'; +import flatMap = require('lodash/flatMap'); export class ValueParser { @@ -52,11 +55,48 @@ class DateTimeParser extends DateParser { } } + +class ChoiceListParser extends ValueParser { + public cleanParse(value: string): string[] | null { + value = value.trim(); + const result = ( + this._parseJson(value) || + this._parseCsv(value) + ).map(v => v.trim()) + .filter(v => v); + if (!result.length) { + return null; + } + return ["L", ...result]; + } + + private _parseJson(value: string): string[] | undefined { + // Don't parse JSON non-arrays + if (value[0] === "[") { + const arr: unknown[] | null = safeJsonParse(value, null); + return arr + // Remove nulls and empty strings + ?.filter(v => v || v === 0) + // Convert values to strings, formatting nested JSON objects/arrays as JSON + .map(v => formatDecoded(v)); + } + } + + private _parseCsv(value: string): string[] { + // Split everything on newlines which are not allowed by the choice editor. + return flatMap(value.split(/[\n\r]+/), row => { + return csvDecodeRow(row) + .map(v => v.trim()); + }); + } +} + const parsers: { [type: string]: typeof ValueParser } = { Numeric: NumericParser, Int: NumericParser, Date: DateParser, DateTime: DateTimeParser, + ChoiceList: ChoiceListParser, }; // TODO these are not ready yet