expression field added in document editor. a few plans in plans folder

This commit is contained in:
2026-02-24 11:38:56 +02:00
parent 74663229d8
commit ac6b1f48b4
16 changed files with 1316 additions and 3 deletions
@@ -0,0 +1,257 @@
function tokenize(expression) {
const tokens = [];
let i = 0;
const src = expression;
while (i < src.length) {
if (/\s/.test(src[i])) {
i++;
continue;
}
if (src[i] === '[') {
let j = i + 1;
while (j < src.length && src[j] !== ']') j++;
tokens.push({ type: 'FIELD_REF', value: src.slice(i, j + 1) });
i = j + 1;
continue;
}
if (/[0-9]/.test(src[i]) || (src[i] === '.' && /[0-9]/.test(src[i + 1] || ''))) {
let j = i;
while (j < src.length && /[0-9.]/.test(src[j])) j++;
tokens.push({ type: 'NUMBER', value: src.slice(i, j) });
i = j;
continue;
}
if (src[i] === '"' || src[i] === "'") {
const quote = src[i];
let j = i + 1;
while (j < src.length && src[j] !== quote) j++;
tokens.push({ type: 'STRING', value: src.slice(i + 1, j) });
i = j + 1;
continue;
}
if (/[A-Za-z_]/.test(src[i])) {
let j = i;
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
const word = src.slice(i, j);
tokens.push({ type: 'IDENT', value: word });
i = j;
continue;
}
if (src[i] === '!' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '!=' });
i += 2;
continue;
}
if (src[i] === '>' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '>=' });
i += 2;
continue;
}
if (src[i] === '<' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '<=' });
i += 2;
continue;
}
if (src[i] === '=') { tokens.push({ type: 'CMP', value: '=' }); i++; continue; }
if (src[i] === '>') { tokens.push({ type: 'CMP', value: '>' }); i++; continue; }
if (src[i] === '<') { tokens.push({ type: 'CMP', value: '<' }); i++; continue; }
if (src[i] === '+' || src[i] === '-' || src[i] === '*' || src[i] === '/') {
tokens.push({ type: 'OP', value: src[i] });
i++;
continue;
}
if (src[i] === '(') { tokens.push({ type: 'LPAREN', value: '(' }); i++; continue; }
if (src[i] === ')') { tokens.push({ type: 'RPAREN', value: ')' }); i++; continue; }
if (src[i] === ',') { tokens.push({ type: 'COMMA', value: ',' }); i++; continue; }
i++;
}
tokens.push({ type: 'EOF', value: '' });
return tokens;
}
function parse(tokens) {
let pos = 0;
const peek = () => tokens[pos];
const consume = () => tokens[pos++];
function parseExpression() {
return parseComparison();
}
function parseComparison() {
const left = parseAdditive();
if (peek().type === 'CMP') {
const op = consume().value;
const right = parseAdditive();
return { kind: 'cmp', op, left, right };
}
return left;
}
function parseAdditive() {
let node = parseMultiplicative();
while (peek().type === 'OP' && (peek().value === '+' || peek().value === '-')) {
const op = consume().value;
const right = parseMultiplicative();
node = { kind: 'binop', op, left: node, right };
}
return node;
}
function parseMultiplicative() {
let node = parseUnary();
while (peek().type === 'OP' && (peek().value === '*' || peek().value === '/')) {
const op = consume().value;
const right = parseUnary();
node = { kind: 'binop', op, left: node, right };
}
return node;
}
function parseUnary() {
if (peek().type === 'OP' && peek().value === '-') {
consume();
const operand = parseUnary();
return { kind: 'unary', op: '-', operand };
}
return parsePrimary();
}
function parsePrimary() {
const tok = peek();
if (tok.type === 'NUMBER') {
consume();
return { kind: 'number', value: parseFloat(tok.value) };
}
if (tok.type === 'STRING') {
consume();
return { kind: 'string', value: tok.value };
}
if (tok.type === 'FIELD_REF') {
consume();
return { kind: 'field', ref: tok.value };
}
if (tok.type === 'IDENT' && tok.value.toUpperCase() === 'IF') {
consume();
if (peek().type !== 'LPAREN') throw new Error('Expected ( after IF');
consume();
const cond = parseExpression();
if (peek().type !== 'COMMA') throw new Error('Expected , in IF');
consume();
const then = parseExpression();
if (peek().type !== 'COMMA') throw new Error('Expected , in IF');
consume();
const els = parseExpression();
if (peek().type !== 'RPAREN') throw new Error('Expected ) after IF');
consume();
return { kind: 'if', cond, then, els };
}
if (tok.type === 'LPAREN') {
consume();
const node = parseExpression();
if (peek().type !== 'RPAREN') throw new Error('Expected )');
consume();
return node;
}
throw new Error(`Unexpected token: ${tok.type} "${tok.value}"`);
}
return parseExpression();
}
function evalNode(node, resolveField) {
switch (node.kind) {
case 'number':
return node.value;
case 'string':
return node.value;
case 'field': {
const raw = resolveField(node.ref);
return raw;
}
case 'unary': {
const val = evalNode(node.operand, resolveField);
const n = Number(val);
return isNaN(n) ? 0 : -n;
}
case 'binop': {
const l = evalNode(node.left, resolveField);
const r = evalNode(node.right, resolveField);
const ln = Number(l);
const rn = Number(r);
const lv = isNaN(ln) ? 0 : ln;
const rv = isNaN(rn) ? 0 : rn;
if (node.op === '+') return lv + rv;
if (node.op === '-') return lv - rv;
if (node.op === '*') return lv * rv;
if (node.op === '/') {
if (rv === 0) throw { divByZero: true };
return lv / rv;
}
return 0;
}
case 'cmp': {
const l = evalNode(node.left, resolveField);
const r = evalNode(node.right, resolveField);
const ln = Number(l);
const rn = Number(r);
const useNum = !isNaN(ln) && !isNaN(rn);
const lv = useNum ? ln : String(l);
const rv = useNum ? rn : String(r);
if (node.op === '=') return lv == rv;
if (node.op === '!=') return lv != rv;
if (node.op === '>') return lv > rv;
if (node.op === '<') return lv < rv;
if (node.op === '>=') return lv >= rv;
if (node.op === '<=') return lv <= rv;
return false;
}
case 'if': {
const condition = evalNode(node.cond, resolveField);
if (condition) {
return evalNode(node.then, resolveField);
} else {
return evalNode(node.els, resolveField);
}
}
default:
throw new Error(`Unknown node kind: ${node.kind}`);
}
}
export function evaluateExpression(expression, resolveField) {
if (!expression || !expression.trim()) return '';
try {
const tokens = tokenize(expression);
const ast = parse(tokens);
const result = evalNode(ast, resolveField);
return result !== undefined && result !== null ? String(result) : '';
} catch (e) {
if (e && e.divByZero) return '#DIV/0';
return '#ERR';
}
}