The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
/**
|
||
* Built-in functions: a plain table { NAME -> fn(values) }.
|
||
*
|
||
* By the time a function runs, the evaluator has already:
|
||
* - resolved every range argument into a flat list of cell values,
|
||
* - flattened scalar arguments into that same list,
|
||
* - checked for errors among the values (an error short-circuits and is
|
||
* returned instead of calling the function).
|
||
*
|
||
* So a function only ever sees `(number | string | null)[]`. Each function
|
||
* decides for itself how to treat text and empty cells. Per spec:
|
||
* - empty cell -> ignored by every aggregate, never counted by COUNT
|
||
* - text -> ignored by the numeric aggregates
|
||
*/
|
||
|
||
import { FormulaError, isError, type CellValue } from './types';
|
||
import {
|
||
dateToSerial,
|
||
timeSerial,
|
||
todaySerial,
|
||
nowSerial,
|
||
strictNum,
|
||
serialParts,
|
||
isoWeekNumber,
|
||
endOfMonthSerial,
|
||
addMonthsSerial,
|
||
parseDateTimeLiteral,
|
||
type SerialParts,
|
||
} from './dates';
|
||
|
||
export type FunctionFn = (values: CellValue[]) => CellValue;
|
||
|
||
/** Keep only real numbers (finite). */
|
||
function nums(values: CellValue[]): number[] {
|
||
const out: number[] = [];
|
||
for (const v of values) {
|
||
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** The one truthy convention used everywhere in this engine a condition is
|
||
* needed (IF's condition -- both the formula function and its real
|
||
* short-circuit implementation in evaluator.ts's evalIf() --, a "Mit
|
||
* Schleife" block's IF statement, and AND/OR/NOT below): a number is
|
||
* truthy unless it's exactly `0`; text is truthy unless empty; an empty
|
||
* cell (`null`) is never truthy. Exported so evaluator.ts can reuse the
|
||
* exact same rule instead of duplicating it. */
|
||
export function isTruthy(v: CellValue): boolean {
|
||
return typeof v === 'number' ? v !== 0 : v !== null && v !== '';
|
||
}
|
||
|
||
/** Check the argument count and coerce every argument to a number (see
|
||
* `strictNum`) -- the shared prologue of the numeric date functions. An
|
||
* optional argument left out (or an empty `;;` slot) comes back as
|
||
* `undefined`. */
|
||
function numArgs(
|
||
name: string,
|
||
values: CellValue[],
|
||
min: number,
|
||
max: number,
|
||
): [number, ...(number | undefined)[]] | FormulaError {
|
||
if (values.length < min || values.length > max) {
|
||
const range = min === max ? `${min}` : `${min}–${max}`;
|
||
return new FormulaError('#ERR', `${name} erwartet ${range} Argument${max === 1 ? '' : 'e'}`);
|
||
}
|
||
const out: Array<number | undefined> = [];
|
||
for (let i = 0; i < values.length; i++) {
|
||
if (i >= min && values[i] === null) {
|
||
out.push(undefined);
|
||
continue;
|
||
}
|
||
const n = strictNum(values[i]);
|
||
if (isError(n)) return n;
|
||
out.push(n);
|
||
}
|
||
return out as [number, ...(number | undefined)[]];
|
||
}
|
||
|
||
/** YEAR/MONTH/DAY/HOUR/MINUTE/SECOND: one serial in, one field out. */
|
||
function part(name: string, pick: (p: SerialParts) => number): FunctionFn {
|
||
return (values) => {
|
||
const a = numArgs(name, values, 1, 1);
|
||
if (isError(a)) return a;
|
||
return pick(serialParts(a[0]));
|
||
};
|
||
}
|
||
|
||
function holidaySet(values: CellValue[]): Set<number> {
|
||
const out = new Set<number>();
|
||
for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day);
|
||
return out;
|
||
}
|
||
|
||
function isWorkday(day: number, holidays: Set<number>): boolean {
|
||
return serialParts(day).weekday <= 5 && !holidays.has(day);
|
||
}
|
||
|
||
/** DATEVALUE/TIMEVALUE's single argument: a recognizable date/time text, or
|
||
* a number (already a serial). */
|
||
function textDateArg(name: string, values: CellValue[]): number | FormulaError {
|
||
if (values.length !== 1) return new FormulaError('#ERR', `${name} erwartet 1 Argument`);
|
||
const v = values[0];
|
||
if (typeof v === 'number') return v;
|
||
const hit = typeof v === 'string' ? parseDateTimeLiteral(v) : null;
|
||
if (!hit) return new FormulaError('#VALUE!', `${name}: '${v ?? ''}' ist kein Datum/keine Uhrzeit`);
|
||
return hit.serial;
|
||
}
|
||
|
||
export const FUNCTIONS: Record<string, FunctionFn> = {
|
||
SUM(values) {
|
||
return nums(values).reduce((a, b) => a + b, 0);
|
||
},
|
||
|
||
AVG(values) {
|
||
const n = nums(values);
|
||
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
|
||
return n.reduce((a, b) => a + b, 0) / n.length;
|
||
},
|
||
|
||
MIN(values) {
|
||
const n = nums(values);
|
||
return n.length ? Math.min(...n) : 0;
|
||
},
|
||
|
||
MAX(values) {
|
||
const n = nums(values);
|
||
return n.length ? Math.max(...n) : 0;
|
||
},
|
||
|
||
COUNT(values) {
|
||
return nums(values).length;
|
||
},
|
||
|
||
COUNT2(values) {
|
||
return values.length;
|
||
},
|
||
|
||
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
|
||
// as the `&` operator (toText() in evaluator.ts): a number prints plain
|
||
// (no locale formatting), an empty cell contributes ''.
|
||
CONCAT(values) {
|
||
return values.map((v) => (v === null ? '' : String(v))).join('');
|
||
},
|
||
|
||
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
|
||
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
|
||
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
|
||
// division when B1=0). This entry only exists so IF is a registered
|
||
// built-in for hasFunction()'s sake (the store's custom-function name
|
||
// check then correctly refuses to let a user shadow it) and as a
|
||
// defensive, best-effort fallback if this were ever reached directly
|
||
// (values are already-eagerly-computed here, so it can't short-circuit --
|
||
// see evaluator.ts's evalIf() for the real implementation).
|
||
IF(values) {
|
||
if (values.length !== 2 && values.length !== 3) {
|
||
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
|
||
}
|
||
const [cond, thenValue, elseValue] = values;
|
||
if (isError(cond)) return cond;
|
||
if (isTruthy(cond)) return thenValue;
|
||
return values.length === 3 ? elseValue : 0;
|
||
},
|
||
|
||
// Ordinary (non-short-circuit) functions -- like real Excel, EVERY
|
||
// argument is evaluated regardless of the others (so e.g.
|
||
// =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) --
|
||
// no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range
|
||
// argument flattens into one truthy check per cell, same as Excel.
|
||
AND(values) {
|
||
if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument');
|
||
return values.every(isTruthy) ? 1 : 0;
|
||
},
|
||
|
||
OR(values) {
|
||
if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument');
|
||
return values.some(isTruthy) ? 1 : 0;
|
||
},
|
||
|
||
NOT(values) {
|
||
if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument');
|
||
return isTruthy(values[0]) ? 0 : 1;
|
||
},
|
||
|
||
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
|
||
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
|
||
// same as everywhere else in this engine; a non-numeric text argument is
|
||
// `#VALUE!`.
|
||
DATE(values) {
|
||
if (values.length !== 3) {
|
||
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
|
||
}
|
||
const y = strictNum(values[0]);
|
||
if (isError(y)) return y;
|
||
const mo = strictNum(values[1]);
|
||
if (isError(mo)) return mo;
|
||
const d = strictNum(values[2]);
|
||
if (isError(d)) return d;
|
||
return dateToSerial(y, mo, d);
|
||
},
|
||
|
||
TIME(values) {
|
||
if (values.length !== 3) {
|
||
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
|
||
}
|
||
const h = strictNum(values[0]);
|
||
if (isError(h)) return h;
|
||
const mi = strictNum(values[1]);
|
||
if (isError(mi)) return mi;
|
||
const s = strictNum(values[2]);
|
||
if (isError(s)) return s;
|
||
return timeSerial(h, mi, s);
|
||
},
|
||
|
||
// Not "volatile": this engine only recalculates on precedent change, so a
|
||
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
|
||
// or recalculated, not spontaneously when the clock ticks over (documented
|
||
// limitation, see README.md's "Bekannte Grenzen").
|
||
TODAY(values) {
|
||
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
|
||
return todaySerial();
|
||
},
|
||
|
||
NOW(values) {
|
||
if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente');
|
||
return nowSerial();
|
||
},
|
||
|
||
// ---- date/time parts & calendar arithmetic ----------------------------
|
||
// All take date serials (see dates.ts). Where Excel has a US-centric
|
||
// default, the ISO 8601 convention is the default here: weeks start on
|
||
// Monday (WEEKDAY -> 1 = Monday), week numbers are ISO weeks.
|
||
YEAR: part('YEAR', (p) => p.year),
|
||
MONTH: part('MONTH', (p) => p.month),
|
||
DAY: part('DAY', (p) => p.date),
|
||
HOUR: part('HOUR', (p) => p.hour),
|
||
MINUTE: part('MINUTE', (p) => p.minute),
|
||
SECOND: part('SECOND', (p) => p.second),
|
||
|
||
// WEEKDAY(Datum; [Typ]) -- Typ as in Excel: 1 = Sunday 1 ... Saturday 7,
|
||
// 2 = Monday 1 ... Sunday 7 (ISO, the default here), 3 = Monday 0 ...
|
||
// Sunday 6.
|
||
WEEKDAY(values) {
|
||
const a = numArgs('WEEKDAY', values, 1, 2);
|
||
if (isError(a)) return a;
|
||
const iso = serialParts(a[0]).weekday;
|
||
switch (a[1] ?? 2) {
|
||
case 1:
|
||
return (iso % 7) + 1;
|
||
case 2:
|
||
return iso;
|
||
case 3:
|
||
return iso - 1;
|
||
default:
|
||
return new FormulaError('#VALUE!', 'WEEKDAY: Typ muss 1, 2 oder 3 sein');
|
||
}
|
||
},
|
||
|
||
ISOWEEKNUM(values) {
|
||
const a = numArgs('ISOWEEKNUM', values, 1, 1);
|
||
if (isError(a)) return a;
|
||
return isoWeekNumber(a[0]);
|
||
},
|
||
|
||
// WEEKNUM(Datum; [Typ]) -- ISO week by default (Typ 21, as in Excel);
|
||
// Typ 1 = weeks start Sunday, 2 = weeks start Monday, both counting the
|
||
// week containing 1 January as week 1 (Excel's US-style numbering).
|
||
WEEKNUM(values) {
|
||
const a = numArgs('WEEKNUM', values, 1, 2);
|
||
if (isError(a)) return a;
|
||
const type = a[1] ?? 21;
|
||
if (type === 21) return isoWeekNumber(a[0]);
|
||
if (type !== 1 && type !== 2) return new FormulaError('#VALUE!', 'WEEKNUM: Typ muss 1, 2 oder 21 sein');
|
||
const p = serialParts(a[0]);
|
||
const jan1 = Math.round(dateToSerial(p.year, 1, 1));
|
||
const jan1Iso = serialParts(jan1).weekday; // 1 = Monday
|
||
const offset = type === 2 ? jan1Iso - 1 : jan1Iso % 7; // days since week start
|
||
return Math.floor((p.day - jan1 + offset) / 7) + 1;
|
||
},
|
||
|
||
EDATE(values) {
|
||
const a = numArgs('EDATE', values, 2, 2);
|
||
if (isError(a)) return a;
|
||
return addMonthsSerial(a[0], Math.trunc(a[1] ?? 0));
|
||
},
|
||
|
||
EOMONTH(values) {
|
||
const a = numArgs('EOMONTH', values, 2, 2);
|
||
if (isError(a)) return a;
|
||
return endOfMonthSerial(a[0], Math.trunc(a[1] ?? 0));
|
||
},
|
||
|
||
// DAYS(Ende; Start) -- whole calendar days between two dates.
|
||
DAYS(values) {
|
||
const a = numArgs('DAYS', values, 2, 2);
|
||
if (isError(a)) return a;
|
||
return serialParts(a[0]).day - serialParts(a[1] ?? 0).day;
|
||
},
|
||
|
||
// DATEDIF(Start; Ende; Einheit) -- "Y", "M", "D" complete years/months/
|
||
// days, "MD"/"YM"/"YD" the remainder ignoring months+years/years.
|
||
DATEDIF(values) {
|
||
if (values.length !== 3) {
|
||
return new FormulaError('#ERR', 'DATEDIF erwartet 3 Argumente (Start; Ende; Einheit)');
|
||
}
|
||
const s0 = strictNum(values[0]);
|
||
if (isError(s0)) return s0;
|
||
const e0 = strictNum(values[1]);
|
||
if (isError(e0)) return e0;
|
||
const unit = typeof values[2] === 'string' ? values[2].trim().toUpperCase() : '';
|
||
const s = serialParts(s0);
|
||
const e = serialParts(e0);
|
||
if (s.day > e.day) return new FormulaError('#VALUE!', 'DATEDIF: Start liegt nach Ende');
|
||
let months = (e.year - s.year) * 12 + (e.month - s.month);
|
||
if (e.date < s.date) months -= 1;
|
||
switch (unit) {
|
||
case 'D':
|
||
return e.day - s.day;
|
||
case 'M':
|
||
return months;
|
||
case 'Y':
|
||
return Math.floor(months / 12);
|
||
case 'YM':
|
||
return months % 12;
|
||
case 'MD':
|
||
return e.day - addMonthsSerial(s.day, months);
|
||
case 'YD': {
|
||
const years = Math.floor(months / 12);
|
||
return e.day - addMonthsSerial(s.day, years * 12);
|
||
}
|
||
default:
|
||
return new FormulaError('#VALUE!', 'DATEDIF: Einheit muss "Y", "M", "D", "MD", "YM" oder "YD" sein');
|
||
}
|
||
},
|
||
|
||
// NETWORKDAYS(Start; Ende; [Feiertage…]) -- Monday-Friday days in the
|
||
// span, both ends included, minus any listed holiday on a weekday.
|
||
NETWORKDAYS(values) {
|
||
if (values.length < 2) {
|
||
return new FormulaError('#ERR', 'NETWORKDAYS erwartet mindestens 2 Argumente (Start; Ende; [Feiertage])');
|
||
}
|
||
const s0 = strictNum(values[0]);
|
||
if (isError(s0)) return s0;
|
||
const e0 = strictNum(values[1]);
|
||
if (isError(e0)) return e0;
|
||
const holidays = holidaySet(values.slice(2));
|
||
const a = serialParts(s0).day;
|
||
const b = serialParts(e0).day;
|
||
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
||
if (hi - lo > 366 * 1000) return new FormulaError('#VALUE!', 'NETWORKDAYS: Zeitraum zu groß');
|
||
let n = 0;
|
||
for (let d = lo; d <= hi; d++) if (isWorkday(d, holidays)) n++;
|
||
return a <= b ? n : -n;
|
||
},
|
||
|
||
// WORKDAY(Start; Tage; [Feiertage…]) -- the date `Tage` working days
|
||
// (Monday-Friday, not a holiday) after (or, negative, before) Start.
|
||
WORKDAY(values) {
|
||
if (values.length < 2) {
|
||
return new FormulaError('#ERR', 'WORKDAY erwartet mindestens 2 Argumente (Start; Tage; [Feiertage])');
|
||
}
|
||
const s0 = strictNum(values[0]);
|
||
if (isError(s0)) return s0;
|
||
const n0 = strictNum(values[1]);
|
||
if (isError(n0)) return n0;
|
||
let left = Math.trunc(Math.abs(n0));
|
||
if (left > 366 * 1000) return new FormulaError('#VALUE!', 'WORKDAY: zu viele Tage');
|
||
const step = n0 < 0 ? -1 : 1;
|
||
const holidays = holidaySet(values.slice(2));
|
||
let d = serialParts(s0).day;
|
||
while (left > 0) {
|
||
d += step;
|
||
if (isWorkday(d, holidays)) left--;
|
||
}
|
||
return d;
|
||
},
|
||
|
||
// DATEVALUE("2026-09-02") / TIMEVALUE("14:30") -- the date resp. time part
|
||
// of a text in any format a cell entry is recognized in (ISO, 02.09.2026,
|
||
// 14:30, …). A number (a cell already holding a date) passes through.
|
||
DATEVALUE(values) {
|
||
const v = textDateArg('DATEVALUE', values);
|
||
if (isError(v)) return v;
|
||
return serialParts(v).day;
|
||
},
|
||
|
||
TIMEVALUE(values) {
|
||
const v = textDateArg('TIMEVALUE', values);
|
||
if (isError(v)) return v;
|
||
const p = serialParts(v);
|
||
return (p.hour * 3600 + p.minute * 60 + p.second) / 86400;
|
||
},
|
||
|
||
// XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
|
||
// [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases
|
||
// this name *before* ever reaching FUNCTIONS. Two reasons, not just one:
|
||
// 1. WennNichtGefunden must only be evaluated when actually needed
|
||
// (short-circuit, same reasoning as IF's branches).
|
||
// 2. By the time a function gets here, EVERY argument -- including
|
||
// Suchbereich and Rückgabebereich, both ranges -- has already been
|
||
// flattened into ONE combined array (see the module comment above).
|
||
// That destroys the boundary between the two ranges, so there is no
|
||
// way to even attempt a best-effort fallback here: unlike IF's
|
||
// 2-3 scalar arguments, XLOOKUP's array arguments can't be recovered
|
||
// from a flat `values` list of unknown split point. This entry exists
|
||
// purely so XLOOKUP is a registered built-in (hasFunction() /
|
||
// name-reservation) -- see evaluator.ts's evalXLookup() for the real
|
||
// implementation. Reaching this in practice would be an engine bug.
|
||
XLOOKUP() {
|
||
return new FormulaError('#ERR', 'XLOOKUP must be evaluated by evaluator.ts, not called directly');
|
||
},
|
||
};
|
||
|
||
// AVERAGE is an alias of AVG.
|
||
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
||
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
|
||
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
|
||
|
||
/** One-line signature hint per built-in, for the formula-autocomplete
|
||
* dropdown (see store/document.ts's `functionSuggestions()` and
|
||
* src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept
|
||
* honest by a test asserting its names exactly match `FUNCTIONS`'s keys,
|
||
* so a future new built-in without a matching entry here fails loudly
|
||
* instead of just silently missing from the dropdown. */
|
||
export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [
|
||
{ name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' },
|
||
{ name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' },
|
||
{ name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' },
|
||
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
|
||
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
|
||
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
|
||
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
|
||
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
|
||
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
|
||
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
|
||
{ name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' },
|
||
{ name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' },
|
||
{ name: 'NOT', hint: 'NOT(Bedingung)' },
|
||
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
|
||
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
|
||
{ name: 'TODAY', hint: 'TODAY()' },
|
||
{ name: 'NOW', hint: 'NOW()' },
|
||
{ name: 'YEAR', hint: 'YEAR(Datum)' },
|
||
{ name: 'MONTH', hint: 'MONTH(Datum)' },
|
||
{ name: 'DAY', hint: 'DAY(Datum)' },
|
||
{ name: 'HOUR', hint: 'HOUR(Zeit)' },
|
||
{ name: 'MINUTE', hint: 'MINUTE(Zeit)' },
|
||
{ name: 'SECOND', hint: 'SECOND(Zeit)' },
|
||
{ name: 'WEEKDAY', hint: 'WEEKDAY(Datum; Typ?)' },
|
||
{ name: 'ISOWEEKNUM', hint: 'ISOWEEKNUM(Datum)' },
|
||
{ name: 'WEEKNUM', hint: 'WEEKNUM(Datum; Typ?)' },
|
||
{ name: 'EDATE', hint: 'EDATE(Datum; Monate)' },
|
||
{ name: 'EOMONTH', hint: 'EOMONTH(Datum; Monate)' },
|
||
{ name: 'DAYS', hint: 'DAYS(Ende; Start)' },
|
||
{ name: 'DATEDIF', hint: 'DATEDIF(Start; Ende; Einheit)' },
|
||
{ name: 'NETWORKDAYS', hint: 'NETWORKDAYS(Start; Ende; Feiertage?)' },
|
||
{ name: 'WORKDAY', hint: 'WORKDAY(Start; Tage; Feiertage?)' },
|
||
{ name: 'DATEVALUE', hint: 'DATEVALUE(Text)' },
|
||
{ name: 'TIMEVALUE', hint: 'TIMEVALUE(Text)' },
|
||
{ name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' },
|
||
];
|
||
|
||
export function hasFunction(name: string): boolean {
|
||
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
|
||
}
|
||
|
||
export function getFunction(name: string): FunctionFn | undefined {
|
||
return FUNCTIONS[name.toUpperCase()];
|
||
}
|