import { get, isEqual, isNil } from 'lodash'; import moment from 'moment'; import { FieldExpression, FieldExpressionOp, LogicalExpression, LogicalExpressionOp, Selector } from './schema'; interface Fields { [key: string]: any; } export const evaluateSelector = ( selector: Selector, fields: Fields, context: EvaluationContext = {}, operator: LogicalExpressionOp = 'and' ): boolean => { if (isNil(selector)) { return true; } return selector .map((expression: FieldExpression | LogicalExpression): boolean => { if (isFieldExpression(expression)) { const fieldValue = fields[expression.field]; const a = isISODateString(fieldValue) ? moment(fieldValue) : fieldValue; const b = parseTokens(expression.value, context); return evalOp(expression.op, a, b); } else if (isLogicalExpression(expression)) { const op = expression.op ?? 'and'; return evaluateSelector(expression.expressions, fields, context, op); } else { throw Error('invalid selector expression!'); } }) .reduce((memo: boolean, val: boolean, index: number) => { if (index === 0) { return val; } if (operator === 'and') { return memo && val; } if (operator === 'or') { return memo || val; } throw Error(`invalid selector operator: ${operator}`); }, false); }; export const isLogicalExpression = (expression: any): expression is LogicalExpression => !!expression.expressions; export const isFieldExpression = (expression: any): expression is FieldExpression => !!expression.field; export const evalOp = (op: FieldExpressionOp, a: any, b: any): boolean => { switch (op) { case '=': if (b === null) { return isNil(a); } return isEqual(a, b); case '!=': if (b === null) { return !isNil(a); } return !isEqual(a, b); case '<': return a < b; case '>': return a > b; case '<=': return a <= b; case '>=': return a >= b; case 'in': return !!(b as any[] | string)?.includes?.(a); case '!in': return !(b as any[] | string)?.includes?.(a); case 'includes': return !!(a as any[] | string)?.includes?.(b); case '!includes': return !(a as any[] | string)?.includes?.(b); default: return false; } }; export const isISODateString = (possibleString: unknown): boolean => { return typeof possibleString === 'string' && !isNil(possibleString.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)); }; export interface EvaluationContext { resolveCurrentUserId?(): number; resolveCard?(): { [key: string]: unknown }; } export const parseTokens = (value: unknown, context: EvaluationContext = {}): any => { if (value === '{null}') { return null; } if (value === '{me}') { return context.resolveCurrentUserId?.(); } if (value === '{timestamp}') { return String(Date.now()); } if (value instanceof Array) { return value.map((item: any) => parseTokens(item, context)); } const date = parseDateToken(value); if (!isNil(date)) { return date; } const cardField = parseCardFieldToken(value, context.resolveCard); if (!!cardField) { return cardField; } return value; }; const parseCardFieldToken = (value: any, resolveCard: EvaluationContext['resolveCard']): any => { if (typeof value !== 'string') { return undefined; } const match = value.match(/^{card[.]([A-Za-z0-9-._\[\]]+)}$/); const card = resolveCard?.(); if (isNil(match) || isNil(card)) { return undefined; } const [{}, fieldName] = match; return get(card, fieldName); }; const parseDateToken = (value: any): Date | undefined => { if (typeof value !== 'string') { return undefined; } if (value === '{now}') { return new Date(); } if (value === '{today}') { return moment().startOf('day').toDate(); } if (isISODateString(value)) { return moment(value).toDate(); } const match = value.match(/^{(today|now)([+-][0-9]+)?(weeks?|days?|hours?|minutes?|seconds?)?}$/); if (!isNil(match)) { const [{}, relativeTo, countString, unit] = match; const count = Number(countString); if (relativeTo === 'now') { return moment() .add(count, unit as moment.unitOfTime.DurationAs) .toDate(); } if (relativeTo === 'today') { return moment() .startOf('day') .add(count, unit as moment.unitOfTime.DurationAs) .toDate(); } } return undefined; };