You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/server/lib/ExcelFormatter.ts

258 lines
8.3 KiB

import {CellValue} from 'app/common/DocActions';
import * as gutil from 'app/common/gutil';
import * as gristTypes from 'app/common/gristTypes';
import {NumberFormatOptions} from 'app/common/NumberFormat';
import {FormatOptions, formatUnknown, IsRightTypeFunc} from 'app/common/ValueFormatter';
import {GristType} from 'app/plugin/GristData';
import {decodeObject} from 'app/plugin/objtypes';
import {Style} from 'exceljs';
import moment from 'moment-timezone';
interface WidgetOptions extends NumberFormatOptions {
textColor?: 'string';
fillColor?: 'string';
alignment?: 'left' | 'center' | 'right';
dateFormat?: string;
timeFormat?: string;
}
class BaseFormatter {
protected isRightType: IsRightTypeFunc;
protected widgetOptions: WidgetOptions;
constructor(public type: string, public opts: FormatOptions) {
this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||
gristTypes.isRightType('Any')!;
this.widgetOptions = opts;
}
/**
* Formats a value that matches the type of this formatter. This should be overridden by derived
* classes to handle values in formatter-specific ways.
*/
public format(value: any): any {
return value;
}
public style(): Partial<Style> {
const argb = (hex: string) => `FF${hex.substr(1)}`;
const style: Partial<Style> = {};
if (this.widgetOptions.fillColor) {
style.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: argb(this.widgetOptions.fillColor) }
};
}
if (this.widgetOptions.textColor) {
style.font = {
color: { argb: argb(this.widgetOptions.textColor) }
};
}
if (this.widgetOptions.alignment) {
style.alignment = {
horizontal: this.widgetOptions.alignment
};
}
if (this.widgetOptions.dateFormat) {
style.numFmt = excelDateFormat(this.widgetOptions.dateFormat, 'yyyy-mm-dd');
}
if (this.widgetOptions.timeFormat) {
style.numFmt = excelDateFormat(this.widgetOptions.dateFormat!, 'yyyy-mm-dd') + ' ' +
excelDateFormat(this.widgetOptions.timeFormat, 'h:mm am/pm');
}
// For number formats - we will support default excel formatting only,
// those formats strings are the defaults that LibreOffice Calc is using.
if (this.widgetOptions.numMode) {
if (this.widgetOptions.numMode === 'currency') {
style.numFmt = '[$$-409]#,##0.00';
} else if (this.widgetOptions.numMode === 'percent') {
style.numFmt = '0.00%';
} else if (this.widgetOptions.numMode === 'decimal') {
style.numFmt = '0.00';
} else if (this.widgetOptions.numMode === 'scientific') {
style.numFmt = '0.00E+00';
}
}
return style;
}
/**
* Formats using this.format() if a value is of the right type for this formatter, or using
* formatUnknown (like AnyFormatter) otherwise, resulting in a string representation.
*/
public formatAny(value: any): any {
return this.isRightType(value) ? this.format(value) : formatUnknown(value);
}
}
class AnyFormatter extends BaseFormatter {
public format(value: any): any {
return formatUnknown(value);
}
}
class ChoiceListFormatter extends BaseFormatter {
public format(value: any): any {
const obj = decodeObject(value);
if (Array.isArray(obj)) {
return obj.join("; ");
}
return formatUnknown(value);
}
}
class UnsupportedFormatter extends BaseFormatter {
public format(value: any): any {
return '';
}
}
class NumberFormatter extends BaseFormatter {
public format(value: any): any {
return Number.isFinite(value) ? value : '';
}
}
class DateFormatter extends BaseFormatter {
private _timezone: string;
constructor(type: string, opts: WidgetOptions, timezone: string = 'UTC') {
opts.dateFormat = opts.dateFormat || 'YYYY-MM-DD';
super(type, opts);
this._timezone = timezone || 'UTC';
// For native conversion - booleans are not a right type.
this.isRightType = (value: CellValue) => typeof value === 'number';
}
public format(value: any): any {
if (value === null) { return ''; }
// convert time to correct timezone
const time = moment(value * 1000).tz(this._timezone);
// in case moment is not able to interpret this as a valid date
// fallback to formatUnknown, for example for 0, NaN, Infinity
if (!time) {
return formatUnknown(value);
}
// make it look like a local time
time.utc(true).local();
// moment objects are mutable so we can just return original object.
return time.toDate();
}
}
class DateTimeFormatter extends DateFormatter {
constructor(type: string, opts: WidgetOptions) {
const timezone = gutil.removePrefix(type, "DateTime:") || '';
opts.timeFormat = opts.timeFormat === undefined ? 'h:mma' : opts.timeFormat;
super(type, opts, timezone);
}
}
const formatters: Partial<Record<GristType, typeof BaseFormatter>> = {
// for numbers - return javascript number
Numeric: NumberFormatter,
Int: NumberFormatter,
// for booleans - return javascript booleans
Bool: BaseFormatter,
// for dates - return javascript Date object
Date: DateFormatter,
DateTime: DateTimeFormatter,
ChoiceList: ChoiceListFormatter,
// for attachments - return blank cell
Attachments: UnsupportedFormatter,
// for anything else - return string (use default AnyFormatter)
};
/**
* Takes column type and format options and returns a constructor with a format function that can
* properly convert a value passed to it into the right javascript object for that column.
* Exceljs library is using javascript primitives to specify correct excel type.
*/
export function createExcelFormatter(type: string, opts: FormatOptions): BaseFormatter {
const ctor = formatters[gristTypes.extractTypeFromColType(type) as GristType] || AnyFormatter;
return new ctor(type, opts);
}
// ----------------------------------------------------------------------
// Helper functions
// ----------------------------------------------------------------------
// Mapping from moment-js basic date format tokens to excel numFmt basic tokens.
// We will convert all our predefined format to excel ones, and try to do our
// best on converting custom formats. If we fail on custom formats we will fall
// back to default ones.
// More on formats can be found:
// https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.numberingformats?view=openxml-2.8.1
// http://officeopenxml.com/WPdateTimeFieldSwitches.php
const mapping = new Map<string, string>();
mapping.set('YYYY', 'yyyy');
mapping.set('YY', 'yy');
mapping.set('M', 'm');
mapping.set('MM', 'mm');
mapping.set('MMM', 'mmm');
mapping.set('MMMM', 'mmmm');
mapping.set('D', 'd');
mapping.set('DD', 'dd');
mapping.set('DDD', 'ddd');
mapping.set('DDDD', 'dddd');
mapping.set('Do', 'dd'); // no direct match
mapping.set('L', 'yyyy-mm-dd');
mapping.set('LL', 'mmmmm d yyyy');
mapping.set('LLL', 'mmmmm d yyyy h:mm am/pm');
mapping.set('LLLL', 'ddd, mmmmm d yyyy h:mm am/pm');
mapping.set('h', 'h');
mapping.set('HH', 'hh');
// Minutes formats are the same as month's ones, but when they are after hour format
// they are treated as minutes.
mapping.set('m', 'm');
mapping.set('mm', 'mm');
mapping.set('mma', 'mm am/pm');
mapping.set('ss', 'ss');
mapping.set('s', 's');
mapping.set('a', 'am/pm');
mapping.set('A', 'am/pm');
mapping.set('S', '0');
mapping.set('SS', '00');
mapping.set('SSS', '000');
mapping.set('SSSS', '0000');
mapping.set('SSSSS', '00000');
mapping.set('SSSSSS', '000000');
// We will omit timezone formats
mapping.set('z', '');
mapping.set('zz', '');
mapping.set('Z', '');
mapping.set('ZZ', '');
/**
* Converts Moment js format string to excel numFormat
* @param format Moment js format string
* @param def Default excel format string
*/
function excelDateFormat(format: string, def: string) {
// split format to chunks by common separator
const chunks = format.split(/([\s:.,-/]+)/);
// try to map chunks
for (let i = 0; i < chunks.length; i += 2) {
const chunk = chunks[i];
if (mapping.has(chunk)) {
chunks[i] = mapping.get(chunk)!;
} else {
// fail on first mismatch
return def;
}
}
// fix the separators - they need to be prefixed by backslash
for (let i = 1; i < chunks.length; i += 2) {
const sep = chunks[i];
if (sep === '-') {
chunks[i] = '\\-';
}
if (sep.trim() === '') {
chunks[i] = '\\' + sep;
}
}
return chunks.join('');
}