import dayjs from 'dayjs' import { chunk, every, get, isNumber, isObject, isString, uniq } from 'lodash' import { toJS } from 'mobx' import { AttributeData, Element, UserElement } from '../..' import { AttributeValue } from '../interfaces' export interface DynamicContext { userElement?: UserElement | null uid?: string element?: Element extraData?: Record } export const DYNAMIC_VALUE_REGEX = /\$\{(.*?)\}/g export type DynamicValueOptions = { allowObjectNotation?: boolean urlEncode?: boolean allowUnparsedOutput?: boolean } export const parseDynamicValue = ( rawValue: string, context: DynamicContext, options: DynamicValueOptions = {}, ): string => { if (typeof rawValue !== 'string') { return rawValue } const matches = rawValue.matchAll(DYNAMIC_VALUE_REGEX) let value = rawValue for (const match of matches) { if (!match || !match[1]) { continue } let parsedValue if (match[1] === 'self.uid' && context.uid) { parsedValue = context.uid } else if (context.element && context.element.data?.[match[1]]) { // LEGACY this.ATTRIBUTE syntax in query parsedValue = parseElementValue( 'this', match[1], undefined, context.element, options, ) } else if (match[1].startsWith('self.') || match[1].startsWith('this.')) { const [elementKey, wantedAttributeKey] = match[1].split('.') parsedValue = parseElementValue( elementKey, wantedAttributeKey, context.userElement || undefined, context.element, options, ) } else { const [extraDataKey, ...extraDataProperties] = match[1].split('.') const extraDataProperty = extraDataProperties.join('.') const potentialValue = get( context.extraData, `${extraDataKey}.${extraDataProperty}`, ) if (typeof potentialValue !== undefined) { parsedValue = potentialValue } } if (parsedValue) { if (typeof parsedValue === 'string' || typeof parsedValue === 'number') { const encodedValue = options.urlEncode ? encodeURIComponent(parsedValue.toString()) : parsedValue.toString() value = value.replace(match[0], encodedValue) } else if (options.allowObjectNotation) { if (options.urlEncode) { value = value.replace( match[0], encodeURIComponent(JSON.stringify(parsedValue)), ) } else { value = parsedValue as unknown as string } } } else if (!options.allowUnparsedOutput) { // if no value found, replace with empty string if its the only value and not object notation if (rawValue === match[0]) { return '' } else { value = value.replace(match[0], '') } } } return value } export const parseTimeRange = ( rawValue: string, context: DynamicContext, options: DynamicValueOptions = {}, ) => { if (typeof rawValue !== 'string') { return rawValue } const matches = rawValue.matchAll(DYNAMIC_VALUE_REGEX) for (const match of matches) { if (!match || !match[1]) { continue } if (match[1].startsWith('timerange.')) { const rangeString = match[1].split(/\.(.+)/)[1] // example rangeString: from($.now.startOf('week')).to(this.some_attribute.endOf('week')) // more info: https://edocu.getoutline.com/doc/premenne-v-listingu-element-query-Lkajd9k6Gz const parsedTimerange = parseTimerange( rangeString, context.userElement || undefined, context.element, ) if (parsedTimerange) { return parsedTimerange } } if (!options.allowUnparsedOutput) { return '' } } return rawValue } export function extractElementValue( element: Element, attribute: string, options: DynamicValueOptions = {}, ): AttributeValue | null | Record { const wantedAttribute = element.data?.[attribute] as AttributeData // check if wanted attribute exists if (!attribute || !wantedAttribute) { return null } // if wanted attribute value is string, return it if (isString(wantedAttribute)) { return wantedAttribute as string } // wanted attribute is multiple, we want listing of -OR- values with $in const wantedAttributeValue = toJS(wantedAttribute.value) if ( typeof wantedAttributeValue === 'string' || typeof wantedAttributeValue === 'number' ) { return wantedAttributeValue } if (Array.isArray(wantedAttributeValue) && options?.allowObjectNotation) { // if geocoordinate if ( every(wantedAttributeValue, isNumber) && wantedAttributeValue.length === 2 ) { return wantedAttribute.value } const mappedValues = wantedAttributeValue.map( (value: unknown) => get(value, 'hash') || get(value, 'uid') || value, ) return { $in: uniq(mappedValues as string[]) } } if (isObject(wantedAttribute.value)) { return ( (wantedAttribute.value as Record).hash || (wantedAttribute.value as Record).uid ) } return null } export function parseChain( chain: string, personContext?: Element, elementContext?: Element, ) { // now chain is $.now.MOMENTFUNC1().MOMENTFUNC2() etc. or this.attributeKey.MOMENTFUNC1().... const [timeKey, timeProperty, ...momentFunctionTokens] = chain.split('.') let baseTime if (timeProperty !== '$') { const propertyValue = parseElementValue( timeKey, timeProperty, personContext, elementContext, { allowObjectNotation: true, }, ) baseTime = isString(propertyValue) ? propertyValue : undefined } // parse additional moment functions here const chainMoment = momentFunctionTokens.reduce( (acc, functionToken: string) => { // discard first index const [, momentFn, argsString] = functionToken.match(/(\b[^()]+)\((.*)\)/) || ([] as string[]) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (momentFn && acc[momentFn]) { const formattedArgs = argsString?.split(',').reduce((argAcc: (string | number)[], arg) => { if (arg.indexOf("'") || arg.indexOf('"')) { // is string, remove quotes argAcc.push(arg.replace(/'/g, '').replace(/"/g, '').trim()) } else { // assume number, parseInt argAcc.push(parseInt(arg, 10)) } return argAcc }, []) || [] // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore acc = acc[momentFn](...formattedArgs) } return acc }, dayjs(baseTime), ) if (typeof chainMoment === 'number' || typeof chainMoment === 'string') { return chainMoment } return chainMoment.toISOString() } export const parseTimerange = ( timerange: string, personContext?: Element, elementContext?: Element, ) => { const chains = timerange.match( /(from)\((.*)\).(to)\((.*)\)|(to)\((.*)\)|(from)\((.*)\)/, ) if (!chains) { return null } // filter out empty matches and full match at index 0 const filteredChains = chains.filter(Boolean).splice(1) const query = chunk(filteredChains, 2).reduce( (acc: Record, [direction, chain]) => { const getDirectionOperator = (dir: string) => dir === 'from' ? '$gte' : '$lte' acc[getDirectionOperator(direction)] = parseChain( chain, personContext, elementContext, ) return acc }, {}, ) return query } export function parseElementValue( elementKey: string, attributeKey: string, personContext?: Element, elementContext?: Element, options: DynamicValueOptions = {}, ) { if (elementKey === 'this' && elementContext) { return extractElementValue(elementContext, attributeKey, options) } else if (elementKey === 'self' && personContext) { return extractElementValue(personContext, attributeKey, options) } return null } // Replace dynamic date variable (chain syntax) with ISOString export function replaceDynamicDate(source: string) { if (typeof source !== 'string') { return source } const patt = getPatternForDynamicDate() return source.replace(patt, (_match, key) => { const chain = key.substring(1, key.length - 1) return parseChain(chain) }) } function getPatternForDynamicDate() { const reg = '\\$\\{time(.*?)\\}' return new RegExp(reg, 'g') }