src/formula.js
Rama main · réplica de lectura del repositorio original.
export const MAX_RESULT_CHARS = 65536;
function joined(parts) {
const out = [];
let chars = 0;
for (const part of parts) {
const s = part == null ? '' : String(part);
chars += s.length;
if (chars > MAX_RESULT_CHARS) throw new Error(`Formula result is longer than ${MAX_RESULT_CHARS} characters`);
out.push(s);
}
return out.join('');
}
const FUNCS = {
if: (c, a, b) => (truthy(c) ? a : b),
concat: (...xs) => joined(xs),
round: (x, n = 0) => {
const f = 10 ** n;
return Math.round(Number(x) * f) / f;
},
abs: (x) => Math.abs(Number(x)),
min: (...xs) => Math.min(...xs.map(Number)),
max: (...xs) => Math.max(...xs.map(Number)),
len: (x) => (x == null ? 0 : Array.isArray(x) ? x.length : String(x).length),
lower: (x) => String(x ?? '').toLowerCase(),
upper: (x) => String(x ?? '').toUpperCase(),
trim: (x) => String(x ?? '').trim(),
contains: (hay, needle) =>
Array.isArray(hay)
? hay.includes(needle)
: String(hay ?? '').toLowerCase().includes(String(needle ?? '').toLowerCase()),
empty: (x) => x == null || x === '' || (Array.isArray(x) && x.length === 0),
today: () => new Date().toISOString().slice(0, 10),
now: () => new Date().toISOString(),
days: (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 86400000),
dateadd: (date, n, unit = 'days') => {
if (date == null || date === '') return null;
const d = new Date(date);
if (Number.isNaN(d.getTime())) return null;
const u = String(unit).replace(/s$/, '');
if (u === 'day') d.setUTCDate(d.getUTCDate() + Number(n));
else if (u === 'week') d.setUTCDate(d.getUTCDate() + Number(n) * 7);
else if (u === 'month') d.setUTCMonth(d.getUTCMonth() + Number(n));
else if (u === 'year') d.setUTCFullYear(d.getUTCFullYear() + Number(n));
else throw new Error(`Unknown date unit '${unit}'`);
const iso = d.toISOString();
return String(date).includes('T') ? iso : iso.slice(0, 10);
},
datediff: (a, b, unit = 'days') => {
const ms = Date.parse(b) - Date.parse(a);
if (Number.isNaN(ms)) return null;
const u = String(unit).replace(/s$/, '');
const per = { day: 86400000, week: 604800000, hour: 3600000, minute: 60000 }[u];
if (!per) throw new Error(`Unknown date unit '${unit}'`);
return Math.round(ms / per);
},
year: (d) => { const p = d && globalThis.weaveDateGrain?.partsOf(d); return p ? p.y : d ? new Date(d).getUTCFullYear() : null; },
month: (d) => { const p = d && globalThis.weaveDateGrain?.partsOf(d); return p ? p.m : d ? new Date(d).getUTCMonth() + 1 : null; },
day: (d) => { const p = d && globalThis.weaveDateGrain?.partsOf(d); return p ? p.d : d ? new Date(d).getUTCDate() : null; },
number: (x) => Number(x),
text: (x) => (x == null ? '' : String(x)),
sortby: (values, keys) => {
if (!Array.isArray(values)) return values;
const ks = Array.isArray(keys) ? keys : [keys];
if (ks.length !== values.length) throw new Error(`sortby needs one key per value (${values.length} values, ${ks.length} keys)`);
const blank = (k) => k == null || k === '';
const cmp = (a, b) => (typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b)));
return values
.map((v, i) => ({ v, k: ks[i], i }))
.sort((a, b) => (blank(a.k) ? (blank(b.k) ? a.i - b.i : 1) : blank(b.k) ? -1 : cmp(a.k, b.k) || a.i - b.i))
.map((x) => x.v);
},
};
Object.setPrototypeOf(FUNCS, null);
function truthy(v) {
return !(v == null || v === false || v === 0 || v === '');
}
function tokenize(src) {
const tokens = [];
let i = 0;
const push = (type, value) => tokens.push({ type, value });
while (i < src.length) {
const ch = src[i];
if (/\s/.test(ch)) { i++; continue; }
if (ch === '[') {
const end = src.indexOf(']', i);
if (end < 0) throw new Error('Unclosed [field] reference');
push('field', src.slice(i + 1, end).trim());
i = end + 1;
continue;
}
if (ch === '"' || ch === "'") {
let j = i + 1, out = '';
while (j < src.length && src[j] !== ch) {
out += src[j] === '\\' ? src[++j] : src[j];
j++;
}
if (j >= src.length) throw new Error('Unclosed string');
push('string', out);
i = j + 1;
continue;
}
if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(src[i + 1] || ''))) {
const m = src.slice(i).match(/^[0-9]*\.?[0-9]+/);
push('number', Number(m[0]));
i += m[0].length;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
const m = src.slice(i).match(/^[A-Za-z_][A-Za-z0-9_]*/);
push('ident', m[0]);
i += m[0].length;
continue;
}
const two = src.slice(i, i + 2);
if (['<=', '>=', '!=', '=='].includes(two)) {
push('op', two === '==' ? '=' : two);
i += 2;
continue;
}
if ('=<>+-*/%(),!'.includes(ch)) {
push('op', ch);
i++;
continue;
}
throw new Error(`Unexpected character '${ch}' in formula`);
}
return tokens;
}
const TOKENS = new Map();
function tokensOf(expression) {
let t = TOKENS.get(expression);
if (!t) {
t = tokenize(expression);
if (TOKENS.size >= 1000) TOKENS.clear();
TOKENS.set(expression, t);
}
return t;
}
export function evaluate(expression, getField) {
const tokens = tokensOf(expression);
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
const expectOp = (v) => {
const t = next();
if (!t || t.type !== 'op' || t.value !== v) throw new Error(`Expected '${v}'`);
};
function parseOr() {
let left = parseAnd();
while (peek()?.type === 'ident' && peek().value === 'or') {
next();
const right = parseAnd();
left = truthy(left) || truthy(right);
}
return left;
}
function parseAnd() {
let left = parseCmp();
while (peek()?.type === 'ident' && peek().value === 'and') {
next();
const right = parseCmp();
left = truthy(left) && truthy(right);
}
return left;
}
function parseCmp() {
const left = parseSum();
const t = peek();
if (t?.type === 'op' && ['=', '!=', '<', '<=', '>', '>='].includes(t.value)) {
next();
const right = parseSum();
switch (t.value) {
case '=': return left === right || String(left) === String(right);
case '!=': return !(left === right || String(left) === String(right));
case '<': return left < right;
case '<=': return left <= right;
case '>': return left > right;
case '>=': return left >= right;
}
}
return left;
}
function parseSum() {
let left = parseProd();
while (peek()?.type === 'op' && ['+', '-'].includes(peek().value)) {
const op = next().value;
const right = parseProd();
if (op === '+') {
left = typeof left === 'string' || typeof right === 'string'
? joined([left, right])
: Number(left) + Number(right);
} else {
left = Number(left) - Number(right);
}
}
return left;
}
function parseProd() {
let left = parseUnary();
while (peek()?.type === 'op' && ['*', '/', '%'].includes(peek().value)) {
const op = next().value;
const right = parseUnary();
left = op === '*' ? Number(left) * Number(right)
: op === '/' ? Number(left) / Number(right)
: Number(left) % Number(right);
}
return left;
}
function parseUnary() {
const t = peek();
if (t?.type === 'op' && t.value === '-') { next(); return -Number(parseUnary()); }
if (t?.type === 'op' && t.value === '!') { next(); return !truthy(parseUnary()); }
return parseAtom();
}
function parseAtom() {
const t = next();
if (!t) throw new Error('Unexpected end of formula');
if (t.type === 'number' || t.type === 'string') return t.value;
if (t.type === 'field') return getField(t.value);
if (t.type === 'ident') {
if (t.value === 'true') return true;
if (t.value === 'false') return false;
if (t.value === 'null') return null;
if (peek()?.type === 'op' && peek().value === '(') {
next();
const args = [];
if (!(peek()?.type === 'op' && peek().value === ')')) {
args.push(parseOr());
while (peek()?.type === 'op' && peek().value === ',') {
next();
args.push(parseOr());
}
}
expectOp(')');
const fn = FUNCS[t.value.toLowerCase()];
if (!fn) throw new Error(`Unknown function '${t.value}'`);
return fn(...args);
}
return getField(t.value);
}
if (t.type === 'op' && t.value === '(') {
const v = parseOr();
expectOp(')');
return v;
}
throw new Error(`Unexpected token in formula`);
}
const result = parseOr();
if (pos !== tokens.length) throw new Error('Trailing tokens in formula');
return result;
}
export function check(expression, fieldNames = []) {
if (!String(expression ?? '').trim()) return { ok: false, error: 'Formula is empty' };
const known = new Set([...fieldNames, 'PublicId']);
try {
evaluate(expression, (name) => {
if (!known.has(name)) throw new Error(`Unknown field '${name}'`);
return 0;
});
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
}
export function references(expression) {
const names = [];
try {
evaluate(expression, (name) => {
if (!names.includes(name)) names.push(name);
return 0;
});
} catch {}
return names;
}