Ctrl K
ComunidadGuías…

src/formula.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;
}