2020-08-21 21:14:42 +00:00
|
|
|
/**
|
|
|
|
* Encodes and decodes Grist encoding of values, mirroring similar Python functions in
|
|
|
|
* sandbox/grist/objtypes.py.
|
|
|
|
*/
|
|
|
|
// tslint:disable:max-classes-per-file
|
|
|
|
|
|
|
|
import {CellValue} from 'app/plugin/GristData';
|
|
|
|
import isPlainObject = require('lodash/isPlainObject');
|
|
|
|
|
|
|
|
// The text to show on cells whose values are pending.
|
|
|
|
export const PENDING_DATA_PLACEHOLDER = "Loading...";
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A GristDate is just a JS Date object whose toString() method returns YYYY-MM-DD.
|
|
|
|
*/
|
|
|
|
export class GristDate extends Date {
|
|
|
|
public static fromGristValue(epochSec: number): GristDate {
|
|
|
|
return new GristDate(epochSec * 1000);
|
|
|
|
}
|
|
|
|
|
|
|
|
public toString() {
|
|
|
|
return this.toISOString().slice(0, 10);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A GristDateTime is a JS Date with an added timezone field. Its toString() returns the date in
|
|
|
|
* ISO format. To create a timezone-aware momentjs object, use:
|
|
|
|
*
|
|
|
|
* moment(d).tz(d.timezone)
|
|
|
|
*/
|
|
|
|
export class GristDateTime extends Date {
|
|
|
|
public static fromGristValue(epochSec: number, timezone: string): GristDateTime {
|
|
|
|
return Object.assign(new GristDateTime(epochSec * 1000), {timezone});
|
|
|
|
}
|
|
|
|
|
|
|
|
public timezone: string;
|
|
|
|
public toString() { return this.toISOString(); }
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A Reference represents a reference to a row in a table. It is simply a pair of a string tableId
|
|
|
|
* and a numeric rowId.
|
|
|
|
*/
|
|
|
|
export class Reference {
|
|
|
|
constructor(public tableId: string, public rowId: number) {}
|
|
|
|
|
|
|
|
public toString(): string {
|
|
|
|
return `${this.tableId}[${this.rowId}]`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-20 20:35:41 +00:00
|
|
|
/**
|
|
|
|
* A ReferenceList represents a reference to a number of rows in a table. It is simply a pair of a string tableId
|
|
|
|
* and a numeric array rowIds.
|
|
|
|
*/
|
|
|
|
export class ReferenceList {
|
|
|
|
constructor(public tableId: string, public rowIds: number[]) {}
|
|
|
|
|
|
|
|
public toString(): string {
|
|
|
|
return `${this.tableId}[[${this.rowIds}]]`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-21 21:14:42 +00:00
|
|
|
/**
|
|
|
|
* A RaisedException represents a formula error. It includes the exception name, message, and
|
|
|
|
* optional details.
|
|
|
|
*/
|
|
|
|
export class RaisedException {
|
|
|
|
constructor(public name: string, public message?: string, public details?: string) {}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* This is designed to look somewhat similar to Excel, e.g. #VALUE or #DIV/0!"
|
|
|
|
*/
|
|
|
|
public toString() {
|
|
|
|
switch (this.name) {
|
|
|
|
case 'ZeroDivisionError': return '#DIV/0!';
|
|
|
|
case 'UnmarshallableError': return this.details || ('#' + this.name);
|
|
|
|
case 'InvalidTypedValue': return `#Invalid ${this.message}: ${this.details}`;
|
|
|
|
}
|
|
|
|
return '#' + this.name;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An UnknownValue is a fallback for values that we don't handle otherwise, e.g. of a Python
|
|
|
|
* formula returned a function object, or a value we fail to decode.
|
|
|
|
* It is typically the Python repr() string of the value.
|
|
|
|
*/
|
|
|
|
export class UnknownValue {
|
|
|
|
// When encoding an unknown value, get a best-effort string form of it.
|
|
|
|
public static safeRepr(value: unknown): string {
|
|
|
|
try {
|
|
|
|
return String(value);
|
|
|
|
} catch (e) {
|
|
|
|
return `<${typeof value}>`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
constructor(public value: unknown) {}
|
|
|
|
public toString() {
|
|
|
|
return String(this.value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A trivial placeholder for a value that's not yet available.
|
|
|
|
*/
|
|
|
|
export class PendingValue {
|
|
|
|
public toString() {
|
|
|
|
return PENDING_DATA_PLACEHOLDER;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-18 15:54:23 +00:00
|
|
|
/**
|
|
|
|
* A trivial placeholder for a value that won't be shown.
|
|
|
|
*/
|
|
|
|
export class SkipValue {
|
|
|
|
public toString() {
|
|
|
|
return '...';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-03 15:01:31 +00:00
|
|
|
/**
|
|
|
|
* A placeholder for a value hidden by access control rules.
|
|
|
|
* Depending on the types of the columns involved, copying
|
|
|
|
* a censored value and pasting elsewhere will either use
|
|
|
|
* CensoredValue.__repr__ (python) or CensoredValue.toString (typescript)
|
|
|
|
* so they should match
|
|
|
|
*/
|
|
|
|
export class CensoredValue {
|
|
|
|
public toString() {
|
|
|
|
return 'CENSORED';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-21 21:14:42 +00:00
|
|
|
/**
|
|
|
|
* Produces a Grist-encoded version of the value, e.g. turning a Date into ['d', timestamp].
|
|
|
|
* Returns ['U', repr(value)] if it fails to encode otherwise.
|
|
|
|
*
|
|
|
|
* TODO Add tests. This is not yet used for anything.
|
|
|
|
*/
|
|
|
|
export function encodeObject(value: unknown): CellValue {
|
|
|
|
try {
|
|
|
|
switch (typeof value) {
|
|
|
|
case 'string':
|
|
|
|
case 'number':
|
|
|
|
case 'boolean':
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
if (value == null) {
|
|
|
|
return null;
|
|
|
|
} else if (value instanceof Reference) {
|
|
|
|
return ['R', value.tableId, value.rowId];
|
2021-08-20 20:35:41 +00:00
|
|
|
} else if (value instanceof ReferenceList) {
|
|
|
|
return ['r', value.tableId, value.rowIds];
|
2020-08-21 21:14:42 +00:00
|
|
|
} else if (value instanceof Date) {
|
|
|
|
const timestamp = value.valueOf() / 1000;
|
|
|
|
if ('timezone' in value) {
|
|
|
|
return ['D', timestamp, (value as GristDateTime).timezone];
|
|
|
|
} else {
|
|
|
|
// TODO Depending on how it's used, may want to return ['d', timestamp] for UTC midnight.
|
|
|
|
return ['D', timestamp, 'UTC'];
|
|
|
|
}
|
2021-06-03 15:01:31 +00:00
|
|
|
} else if (value instanceof CensoredValue) {
|
|
|
|
return ['C'];
|
2020-08-21 21:14:42 +00:00
|
|
|
} else if (value instanceof RaisedException) {
|
|
|
|
return ['E', value.name, value.message, value.details];
|
|
|
|
} else if (Array.isArray(value)) {
|
|
|
|
return ['L', ...value.map(encodeObject)];
|
|
|
|
} else if (isPlainObject(value)) {
|
|
|
|
return ['O', mapValues(value as any, encodeObject, {sort: true})];
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
// Fall through to return a best-effort representation.
|
|
|
|
}
|
|
|
|
// We either don't know how to convert the value, or failed during the conversion. Instead we
|
|
|
|
// return an "UnmarshallableValue" object, with repr() of the value to show to the user.
|
|
|
|
return ['U', UnknownValue.safeRepr(value)];
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a Grist-encoded value, returns an object represented by it.
|
|
|
|
* If the type code is unknown, or construction fails for any reason, returns an UnknownValue.
|
|
|
|
*/
|
|
|
|
export function decodeObject(value: CellValue): unknown {
|
|
|
|
if (!Array.isArray(value)) {
|
|
|
|
return value;
|
|
|
|
}
|
|
|
|
const code: string = value[0];
|
|
|
|
const args: any[] = value.slice(1);
|
|
|
|
let err: Error|undefined;
|
|
|
|
try {
|
|
|
|
switch (code) {
|
|
|
|
case 'D': return GristDateTime.fromGristValue(args[0], String(args[1]));
|
|
|
|
case 'd': return GristDate.fromGristValue(args[0]);
|
|
|
|
case 'E': return new RaisedException(args[0], args[1], args[2]);
|
|
|
|
case 'L': return (args as CellValue[]).map(decodeObject);
|
|
|
|
case 'O': return mapValues(args[0] as {[key: string]: CellValue}, decodeObject, {sort: true});
|
|
|
|
case 'P': return new PendingValue();
|
2021-08-20 20:35:41 +00:00
|
|
|
case 'r': return new ReferenceList(String(args[0]), args[1]);
|
2020-08-21 21:14:42 +00:00
|
|
|
case 'R': return new Reference(String(args[0]), args[1]);
|
2020-11-18 15:54:23 +00:00
|
|
|
case 'S': return new SkipValue();
|
2021-06-03 15:01:31 +00:00
|
|
|
case 'C': return new CensoredValue();
|
2020-08-21 21:14:42 +00:00
|
|
|
case 'U': return new UnknownValue(args[0]);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
err = e;
|
|
|
|
}
|
|
|
|
// If we can't decode, return an UnknownValue with some attempt to represent what we couldn't
|
|
|
|
// decode as long as some info about the error if any.
|
|
|
|
return new UnknownValue(`${code}(${JSON.stringify(args).slice(1, -1)})` +
|
|
|
|
(err ? `#${err.name}(${err.message})` : ''));
|
|
|
|
}
|
|
|
|
|
|
|
|
// Like lodash's mapValues, with support for sorting keys, for friendlier output.
|
|
|
|
export function mapValues<A, B>(
|
|
|
|
sourceObj: {[key: string]: A}, mapper: (value: A) => B, options: {sort?: boolean} = {}
|
|
|
|
): {[key: string]: B} {
|
|
|
|
const result: {[key: string]: B} = {};
|
|
|
|
const keys = Object.keys(sourceObj);
|
|
|
|
if (options.sort) {
|
|
|
|
keys.sort();
|
|
|
|
}
|
|
|
|
for (const key of keys) {
|
|
|
|
result[key] = mapper(sourceObj[key]);
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|