/*
* This file is part of TREB.
*
* TREB is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* TREB is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along
* with TREB. If not, see .
*
* Copyright 2022-2026 trebco, llc.
* info@treb.app
*
*/
import type { FunctionMap, IntrinsicValue } from '../descriptors';
import { NumberFormatCache, ValueParser } from 'treb-format';
import type { CellValue, UnionValue} from 'treb-base-types';
import { Localization, ValueType } from 'treb-base-types';
import * as Utils from '../utilities';
import { ArgumentError, ValueError } from '../function-error';
export const TextFunctionLibrary: FunctionMap = {
Char: {
arguments: [{
name: 'number',
}],
fn: (num: number): UnionValue => {
return { type: ValueType.string, value: String.fromCodePoint(num||32) };
},
category: ['text'],
},
Code: {
arguments: [{
name: 'string',
}],
fn: (str: string): UnionValue => {
return { type: ValueType.number, value: str.codePointAt(0) || 0 }; // FIXME: default?
},
category: ['text'],
},
Value: {
arguments: [
{ name: 'text' },
],
fn: (text: string): UnionValue => {
const value = ValueParser.TryParse(text);
if (value.type === ValueType.number) {
return { type: ValueType.number, value: value.value as number };
}
return ArgumentError();
},
category: ['text'],
},
Text: {
arguments: [
{ name: 'value' },
{ name: 'number format' },
],
fn: (value: number, format = '0.00####'): UnionValue => {
return { type: ValueType.string, value: NumberFormatCache.Get(format).Format(value || 0) };
},
category: ['text'],
},
WildcardMatch: {
visibility: 'internal',
arguments: [
{ name: 'text', unroll: true },
{ name: 'text', unroll: true },
// the invert parameter is optional, defaults to false. we add this
// so we can invert wirhout requiring an extra function call.
{ name: 'invert' },
],
fn: (a: IntrinsicValue, b: IntrinsicValue, invert = false) => {
if (typeof a === 'string' && typeof b === 'string') {
const pattern = Utils.ParseWildcards(b);
const match = new RegExp('^' + pattern + '$', 'i').exec(a);
return {
type: ValueType.boolean,
value: invert ? !match : !!match,
};
}
return {
type: ValueType.boolean,
value: (a === b || a?.toString() === b?.toString()),
}
},
},
Exact: {
arguments: [
{ name: 'text', boxed: true, unroll: true },
{ name: 'text', boxed: true, unroll: true },
],
category: ['text'],
fn: (a: UnionValue, b: UnionValue): UnionValue => {
return {
type: ValueType.boolean,
value: (a?.value?.toString()) === (b?.value?.toString()),
};
},
},
Len: {
arguments: [
{ name: 'string', unroll: true },
],
fn: (str: string): UnionValue => {
return {
type: ValueType.number,
value: str.toString().length,
}
},
},
Substitute: {
arguments: [
{ name: 'text' },
{ name: 'search' },
{ name: 'replacement' },
{ name: 'index' },
],
fn: (text: string, search: string, replacement: string, index?: number): UnionValue => {
if (typeof index === 'number') {
if (index < 1) {
return ValueError();
}
let counter = 1;
return {
type: ValueType.string,
value: text.replaceAll(search, (...args) => {
// console.info(args);
return (counter++) === index ? replacement : search;
}),
};
}
else {
return {
type: ValueType.string,
value: text.replaceAll(search, replacement),
};
}
},
},
Left: {
arguments: [
{ name: 'string' },
{ name: 'count' },
],
fn: (str: string, count = 1): UnionValue => {
return { type: ValueType.string, value: str.substr(0, count) };
},
category: ['text'],
},
Right: {
arguments: [
{ name: 'string' },
{ name: 'count' },
],
fn: (str: string, count = 1): UnionValue => {
return { type: ValueType.string, value: str.slice(-count) };
},
category: ['text'],
},
Mid: {
arguments: [
{ name: 'string' },
{ name: 'left' },
{ name: 'count' },
],
fn: (str: string, left = 0, count = 1): UnionValue => {
return { type: ValueType.string, value: str.substr(Math.max(0, left - 1), count) };
},
category: ['text'],
},
/*
Concatenate: {
description: 'Pastes strings together',
fn: (...args: any[]) => {
return args.map((arg) => {
// this is used when concatenating cells that contain numbers
// FIXME: get cell number format?
const string_arg = (typeof arg === 'undefined') ? '' : arg.toString();
if (typeof arg === 'number' && Localization.decimal_separator === ',') {
return string_arg.replace(/\./, ',');
}
return string_arg;
}).join('');
},
},
*/
/**
* shame we can't write a proper search function, but we need to be
* consistent. some notes:
*
* FIND is case-sensitive and does not support wildcards
* SEARCH is icase and supports wildcards
*
* re: wildcards, from
* https://exceljet.net/glossary/wildcard
*
* Excel has 3 wildcards you can use in your formulas:
*
* Asterisk (*) - zero or more characters
* Question mark (?) - any one character
* Tilde (~) - escape for literal character (~*) a literal question mark (~?), or a literal tilde (~~)
*
* start index is 1-based, and defaults to 1; < 1 is an error. if the string
* is not found, that's an error. if needle is empty, return start.
*
*/
Search: {
description: 'Find a string (needle) in another string (haystack). Case-insensitive.',
arguments: [
{ name: 'Needle', },
{ name: 'Haystack', },
{ name: 'Start', default: 1, },
],
fn: (needle: string, haystack: string, start = 1): UnionValue => {
if (start >= 1) {
if (!needle) {
return {
type: ValueType.number, value: start,
}
}
// translate into regex. do we need an actual parser for this, or
// can we get by with regexes? should we have some sort of cache
// for common patterns?
const pattern = Utils.ParseWildcards(needle);
// console.info('n', needle, 'p', pattern);
const match = new RegExp(pattern, 'i').exec(haystack.substr(start - 1));
if (match) {
return {
type: ValueType.number, value: match.index + start,
}
}
}
return ValueError();
},
},
Find: {
description: 'Find a string (needle) in another string (haystack). Case-sensitive.',
arguments: [
{ name: 'Needle', },
{ name: 'Haystack', },
{ name: 'Start', default: 1, },
],
fn: (needle: string, haystack: string, start = 1): UnionValue => {
if (start >= 1) {
if (!needle) {
return {
type: ValueType.number, value: start,
}
}
needle = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(needle).exec(haystack.substr(start - 1));
if (match) {
return {
type: ValueType.number, value: match.index + start,
}
}
}
return ValueError();
},
},
Upper: {
description: 'Converts text to upper case',
arguments: [{ name: 'text', unroll: true }],
fn: (text?: string) => {
if (text === null || text === undefined) {
return { type: ValueType.undefined };
}
return {
type: ValueType.string, value: text.toString().toUpperCase(),
};
},
},
Lower: {
description: 'Converts text to lower case',
arguments: [{ name: 'text', unroll: true }],
fn: (text?: string) => {
if (text === null || text === undefined) {
return { type: ValueType.undefined };
}
return {
type: ValueType.string, value: text.toString().toLowerCase(),
};
},
},
/** canonical should be CONCAT; concatenate can be an alias */
Concat: {
description: 'Pastes strings together',
arguments: [{
name: 'text',
repeat: true,
}],
fn: (...args: CellValue[]): UnionValue => {
const values = Utils.FlattenCellValues(args);
const value = values.map((arg) => {
// this is used when concatenating cells that contain numbers
// FIXME: get cell number format? we'd need to use metadata
const string_arg = arg?.toString() || '';
if (typeof arg === 'number' && Localization.decimal_separator === ',') {
return string_arg.replace(/\./, ',');
}
return string_arg;
}).join('');
return { type: ValueType.string, value };
},
},
};
export const TextFunctionAliases: Record = {
Concatenate: 'Concat',
};