YetiForceCompany/YetiForceCRM

View on GitHub
app/TextParser.php

Summary

Maintainability
F
2 wks
Test Coverage
D
62%
<?php
/**
 * Text parser file.
 *
 * @package App
 *
 * @copyright YetiForce S.A.
 * @license   YetiForce Public License 6.5 (licenses/LicenseEN.txt or yetiforce.com)
 * @author    Mariusz Krzaczkowski <m.krzaczkowski@yetiforce.com>
 * @author    Radosław Skrzypczak <r.skrzypczak@yetiforce.com>
 */

namespace App;

/**
 * Text parser class.
 */
class TextParser
{
    /**
     * Examples of supported variables.
     *
     * @var array
     */
    public static $variableExamples = [
        'LBL_ORGANIZATION_NAME' => '$(organization : company_name)$',
        'LBL_ORGANIZATION_LOGO' => '$(organization : logo)$',
        'LBL_EMPLOYEE_NAME' => '$(employee : last_name)$',
        'LBL_CRM_DETAIL_VIEW_URL' => '$(record : CrmDetailViewURL)$',
        'LBL_PORTAL_DETAIL_VIEW_URL' => '$(record : PortalDetailViewURL)$',
        'LBL_RECORD_ID' => '$(record : RecordId)$',
        'LBL_RECORD_LABEL' => '$(record : RecordLabel)$',
        'LBL_LIST_OF_CHANGES_IN_RECORD' => '$(record : ChangesListChanges)$',
        'LBL_LIST_OF_NEW_VALUES_IN_RECORD' => '$(record : ChangesListValues)$',
        'LBL_RECORD_COMMENT' => '$(record : Comments 5)$, $(record : Comments)$',
        'LBL_RELATED_RECORD_LABEL' => '$(relatedRecord : parent_id|email1|Accounts)$, $(relatedRecord : parent_id|email1)$',
        'LBL_RELATED_NEXT_LEVEL_RECORD_LABEL' => '$(relatedRecordLevel : projectid|Project|linktoaccountscontacts|email1|Accounts)$',
        'LBL_OWNER_EMAIL' => '$(relatedRecord : assigned_user_id|email1|Users)$',
        'LBL_SOURCE_RECORD_LABEL' => '$(sourceRecord : RecordLabel)$',
        'LBL_CUSTOM_FUNCTION' => '$(custom : ContactsPortalPass)$',
        'LBL_RELATED_RECORDS_LIST' => '$(relatedRecordsList : Contacts|firstname,lastname,email|[[["firstname","a","Tom"]]]||5)$',
        'LBL_RECORDS_LIST' => '$(recordsList : Contacts|firstname,lastname,email|[[["firstname","a","Tom"]]]||5)$',
        'LBL_INVENTORY_TABLE' => '$(inventory : type=table columns=seq,name,qty,unit,price,total,net href=no)$',
        'LBL_DYNAMIC_INVENTORY_TABLE' => '$(custom : dynamicInventoryColumnsTable)$',
        'LBL_BARCODE' => '$(barcode : type=EAN13 class=DNS1D , value=12345678)$',
    ];

    /**
     * Default date list.
     *
     * @var string[]
     */
    public static $variableDates = [
        'LBL_DATE_TODAY' => '$(date : now)$',
        'LBL_DATE_TOMORROW' => '$(date : tomorrow)$',
        'LBL_DATE_YESTERDAY' => '$(date : yesterday)$',
        'LBL_DATE_FIRST_DAY_OF_THIS_WEEK' => '$(date : monday this week)$',
        'LBL_DATE_MONDAY_NEXT_WEEK' => '$(date : monday next week)$',
        'LBL_DATE_FIRST_DAY_OF_THIS_MONTH' => '$(date : first day of this month)$',
        'LBL_DATE_LAST_DAY_OF_THIS_MONTH' => '$(date : last day of this month)$',
        'LBL_DATE_FIRST_DAY_OF_NEXT_MONTH' => '$(date : first day of next month)$',
    ];

    /**
     * Variables for entity modules.
     *
     * @var array
     */
    public static $variableGeneral = [
        'LBL_CURRENT_DATE' => '$(general : CurrentDate)$',
        'LBL_CURRENT_TIME' => '$(general : CurrentTime)$',
        'LBL_BASE_TIMEZONE' => '$(general : BaseTimeZone)$',
        'LBL_USER_TIMEZONE' => '$(general : UserTimeZone)$',
        'LBL_SITE_URL' => '$(general : SiteUrl)$',
        'LBL_PORTAL_URL' => '$(general : PortalUrl)$',
        'LBL_TRANSLATE' => '$(translate : Accounts|LBL_COPY_BILLING_ADDRESS)$, $(translate : LBL_SECONDS)$',
    ];

    /**
     * Variables for entity modules.
     *
     * @var array
     */
    public static $variableEntity = [
        'CrmDetailViewURL' => 'LBL_CRM_DETAIL_VIEW_URL',
        'PortalDetailViewURL' => 'LBL_PORTAL_DETAIL_VIEW_URL',
        'RecordId' => 'LBL_RECORD_ID',
        'RecordLabel' => 'LBL_RECORD_LABEL',
        'ChangesListChanges' => 'LBL_LIST_OF_CHANGES_IN_RECORD',
        'ChangesListValues' => 'LBL_LIST_OF_NEW_VALUES_IN_RECORD',
        'Comments' => 'LBL_RECORD_COMMENT',
        'SummaryFields' => 'LBL_SUMMARY_FIELDS',
    ];

    /**
     * List of available functions.
     *
     * @var string[]
     */
    protected static $baseFunctions = ['general', 'translate', 'record', 'relatedRecord', 'relatedRecordLevel', 'sourceRecord', 'organization', 'employee', 'params', 'custom', 'relatedRecordsList', 'recordsList', 'date', 'inventory', 'userVariable', 'barcode'];

    /**
     * List of source modules.
     *
     * @var array
     */
    public static $sourceModules = [
        'Campaigns' => ['Leads', 'Accounts', 'Contacts', 'Vendors', 'Partners', 'Competition'],
    ];
    /**
     * Record variables.
     *
     * @var array
     */
    protected static $recordVariable = [];
    /**
     * Related variables.
     *
     * @var array
     */
    protected static $relatedVariable = [];
    /**
     * Next level related variables.
     *
     * @var array
     */
    protected static $relatedVariableLevel = [];

    /**
     * Record id.
     *
     * @var int
     */
    public $record;

    /**
     * Module name.
     *
     * @var string
     */
    public $moduleName;

    /**
     * Record model.
     *
     * @var \Vtiger_Record_Model
     */
    public $recordModel;

    /**
     * Parser type.
     *
     * @var string|null
     */
    public $type;

    /**
     * Source record model.
     *
     * @var \Vtiger_Record_Model
     */
    protected $sourceRecordModel;

    /**
     * Content.
     *
     * @var string
     */
    protected $content = '';

    /**
     * Rwa content.
     *
     * @var string
     */
    protected $rawContent;

    /**
     * without translations.
     *
     * @var bool
     */
    protected $withoutTranslations = false;

    /**
     * Language content.
     *
     * @var string
     */
    protected $language;

    /**
     * Additional params.
     *
     * @var array
     */
    protected $params;

    /**
     * Separator to display data when there are several values.
     *
     * @var string
     */
    public $relatedRecordSeparator = ',';

    /**
     * Is the parsing text content html?
     *
     * @var bool
     */
    public $isHtml = true;

    /**
     * Use extended parsing.
     *
     * @var bool
     */
    public $useExtension = false;

    /**
     * Variable parser regex.
     *
     * @var string
     */
    public const VARIABLE_REGEX = '/\$\((\w+) : ([,"\+\#\%\.\:\;\=\-\[\]\&\w\s\|\)\(\:]+)\)\$/u';

    /** @var bool Permissions condition */
    protected $permissions = true;

    /** @var string[] Uitypes with large data */
    protected $largeDataUiTypes = ['multiImage', 'image'];

    /**
     * Get instanace by record id.
     *
     * @param int    $record     Record id
     * @param string $moduleName Module name
     *
     * @return \self
     */
    public static function getInstanceById(int $record, ?string $moduleName = null)
    {
        $class = static::class;
        $instance = new $class();
        $instance->record = $record;
        $instance->recordModel = \Vtiger_Record_Model::getInstanceById($record, $moduleName);
        $instance->moduleName = $instance->recordModel->getModuleName();
        return $instance;
    }

    /**
     * Get instanace by record model.
     *
     * @param \Vtiger_Record_Model $recordModel
     *
     * @return \self
     */
    public static function getInstanceByModel(\Vtiger_Record_Model $recordModel)
    {
        $class = static::class;
        $instance = new $class();
        $instance->record = $recordModel->getId();
        $instance->moduleName = $recordModel->getModuleName();
        $instance->recordModel = $recordModel;
        return $instance;
    }

    /**
     * Get clean instanace.
     *
     * @param string $moduleName Module name
     *
     * @return \self
     */
    public static function getInstance($moduleName = '')
    {
        $class = static::class;
        $instance = new $class();
        if ($moduleName) {
            $instance->moduleName = $moduleName;
        }
        return $instance;
    }

    /**
     * Set the active state of extended parsing functionality.
     *
     * @param bool $state
     *
     * @return $this
     */
    public function setExtensionState(bool $state)
    {
        $this->useExtension = $state;
        return $this;
    }

    /**
     * Set without translations.
     *
     * @param string $type
     *
     * @return $this
     */
    public function withoutTranslations($type = true)
    {
        $this->withoutTranslations = $type;
        return $this;
    }

    /**
     * Set language.
     *
     * @param string $name
     *
     * @return $this
     */
    public function setLanguage($name = true)
    {
        $this->language = $name;
        return $this;
    }

    /**
     * Set parser type.
     *
     * @param string $type
     *
     * @return $this
     */
    public function setType($type)
    {
        $this->type = $type;
        return $this;
    }

    /**
     * Set additional params.
     *
     * @param array $params
     *
     * @return $this
     */
    public function setParams($params)
    {
        $this->params = $params;
        return $this;
    }

    /**
     * Set param value.
     *
     * @param string $key
     * @param mixed  $value
     *
     * @return $this
     */
    public function setParam(string $key, $value)
    {
        $this->params[$key] = $value;
        return $this;
    }

    /**
     * Get additional params.
     *
     * @param string $key
     *
     * @return mixed
     */
    public function getParam(string $key)
    {
        return $this->params[$key] ?? null;
    }

    /**
     * Set source record.
     *
     * @param int         $record
     * @param bool|string $moduleName
     * @param mixed       $recordModel
     *
     * @return $this
     */
    public function setSourceRecord($record, $moduleName = false, $recordModel = false)
    {
        $this->sourceRecordModel = $recordModel ?: \Vtiger_Record_Model::getInstanceById($record, $moduleName ?: Record::getType($record));
        return $this;
    }

    /**
     * Set content.
     *
     * @param string $content
     *
     * @return $this
     */
    public function setContent($content)
    {
        $this->rawContent = $this->content = str_replace(['%20%3A%20', '%20:%20'], ' : ', $content);
        return $this;
    }

    /**
     * Get content.
     *
     * @param mixed $trim
     */
    public function getContent($trim = false)
    {
        return $trim ? trim($this->content) : $this->content;
    }

    /**
     * Function checks if its TextParser type.
     *
     * @param string $text
     *
     * @return int
     */
    public static function isVaribleToParse($text)
    {
        return (int) preg_match(static::VARIABLE_REGEX, $text);
    }

    /**
     * Set permissions condition.
     *
     * @param bool $permitted
     *
     * @return $this
     */
    public function setGlobalPermissions(bool $permitted)
    {
        $this->permissions = $permitted;
        return $this;
    }

    /**
     * All text parse function.
     *
     * @return $this
     */
    public function parse()
    {
        if (empty($this->content)) {
            return $this;
        }
        if (isset($this->language)) {
            Language::setTemporaryLanguage($this->language);
        }
        $this->content = $this->parseData($this->content);
        Language::clearTemporaryLanguage();
        return $this;
    }

    /**
     * Text parse function.
     *
     * @param string $content
     *
     * @return string
     */
    public function parseData(string $content)
    {
        if ($this->useExtension) {
            $content = preg_replace_callback('/<!--[\s]+({% [\s\S]+? %})[\s]+-->/u', fn ($matches) => $matches[1] ?? '', $content);
            $twig = new \Twig\Environment(new \Twig\Loader\ArrayLoader(['index' => $content]));
            $sandbox = new \Twig\Extension\SandboxExtension(\App\Extension\Twig\SecurityPolicy::getPolicy(), true);
            $twig->addExtension($sandbox);
            $twig->addFunction(new \Twig\TwigFunction('YFParser', function ($text) {
                $value = '';
                preg_match(static::VARIABLE_REGEX, $text, $matches);
                if ($matches) {
                    [, $function, $params] = array_pad($matches, 3, '');
                    $value = \in_array($function, static::$baseFunctions) ? $this->{$function}($params) : '';
                }
                return $value;
            }));
            $content = $twig->render('index');
        }
        return preg_replace_callback(static::VARIABLE_REGEX, function ($matches) {
            [, $function, $params] = array_pad($matches, 3, '');
            return \in_array($function, static::$baseFunctions) ? $this->{$function}($params) : '';
        }, $content);
    }

    /**
     * Text parse function.
     *
     * @return $this
     */
    public function parseTranslations()
    {
        if (isset($this->language)) {
            Language::setTemporaryLanguage($this->language);
        }
        $this->content = preg_replace_callback('/\$\(translate : ([,"\+\%\.\=\-\[\]\&\w\s\|]+)\)\$/u', function ($matches) {
            [, $params] = array_pad($matches, 2, '');
            return $this->translate($params);
        }, $this->content);
        Language::clearTemporaryLanguage();
        return $this;
    }

    /**
     * Function parse date.
     *
     * @param string $param
     *
     * @return string
     */
    public function date($param)
    {
        if (isset(\App\Condition::DATE_OPERATORS[$param])) {
            $date = implode(' - ', array_unique(\DateTimeRange::getDateRangeByType($param)));
        } else {
            $date = date('Y-m-d', strtotime($param));
        }
        return $date;
    }

    /**
     * Parsing translations.
     *
     * @param string $params
     *
     * @return string
     */
    protected function translate($params)
    {
        if ($this->withoutTranslations) {
            return "$(translate : $params)$";
        }
        if (false === strpos($params, '|')) {
            return Language::translate($params);
        }
        $splitParams = explode('|', $params);
        $module = array_shift($splitParams);
        $key = array_shift($splitParams);
        return Language::translate($key, $module, $splitParams[0] ?? $this->language);
    }

    /**
     * Parsing organization detail.
     *
     * @param string $params
     *
     * @return string
     */
    protected function organization(string $params): string
    {
        if (!$params) {
            return '';
        }
        $returnVal = '';
        if (false === strpos($params, '|')) {
            $id = User::getCurrentUserModel()->get('multiCompanyId');
            $fieldName = $params;
            $params = false;
        } else {
            [$id, $fieldName, $params] = array_pad(explode('|', $params, 3), 3, false);
        }
        if (Record::isExists($id, 'MultiCompany')) {
            $companyRecordModel = \Vtiger_Record_Model::getInstanceById($id, 'MultiCompany');
            if ($companyRecordModel->has($fieldName)) {
                $value = $companyRecordModel->get($fieldName);
                $fieldModel = $companyRecordModel->getModule()->getFieldByName($fieldName);
                if ('' === $value || !$fieldModel || !$this->useValue($fieldModel, 'MultiCompany')) {
                    return '';
                }
                if ($this->withoutTranslations) {
                    $returnVal = $this->getDisplayValueByType($value, $companyRecordModel, $fieldModel, $params);
                } else {
                    $returnVal = $fieldModel->getUITypeModel()->getTextParserDisplayValue($value, $companyRecordModel, $params);
                }
            }
        }
        return $returnVal;
    }

    /**
     * Parsing employee detail.
     *
     * @param string $fieldName
     *
     * @return mixed
     */
    protected function employee($fieldName)
    {
        $userId = User::getCurrentUserId();
        if (Cache::has('TextParserEmployeeDetail', $userId . $fieldName)) {
            return Cache::get('TextParserEmployeeDetail', $userId . $fieldName);
        }
        if (Cache::has('TextParserEmployeeDetailRows', $userId)) {
            $employee = Cache::get('TextParserEmployeeDetailRows', $userId);
        } else {
            $employee = (new Db\Query())->select(['crmid'])->from('vtiger_crmentity')->where(['deleted' => 0, 'setype' => 'OSSEmployees', 'smownerid' => $userId])
                ->scalar();
            Cache::save('TextParserEmployeeDetailRows', $userId, $employee, Cache::LONG);
        }
        $value = '';
        if ($employee && Record::isExists($employee, 'OSSEmployees')) {
            $relatedRecordModel = \Vtiger_Record_Model::getInstanceById($employee, 'OSSEmployees');
            $instance = static::getInstanceByModel($relatedRecordModel);
            foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
                if (isset($this->{$key})) {
                    $instance->{$key} = $this->{$key};
                }
            }
            $value = $instance->record($fieldName);
        }
        Cache::save('TextParserEmployeeDetail', $userId . $fieldName, $value, Cache::LONG);
        return $value;
    }

    /**
     * Parsing general data.
     *
     * @param string $key
     *
     * @return mixed
     */
    protected function general($key)
    {
        switch ($key) {
            case 'CurrentDate':
                return (new \DateTimeField(null))->getDisplayDate();
            case 'CurrentTime':
                return \Vtiger_Util_Helper::convertTimeIntoUsersDisplayFormat(date('H:i:s'));
            case 'CurrentDateTime':
                return Fields\DateTime::formatToDisplay('now');
            case 'SiteUrl':
                return Config::main('site_URL');
            case 'PortalUrl':
                return Config::main('PORTAL_URL');
            case 'BaseTimeZone':
                return Fields\DateTime::getTimeZone();
            case 'UserTimeZone':
                $userModel = User::getCurrentUserModel();
                return ($userModel && $userModel->getDetail('time_zone')) ? $userModel->getDetail('time_zone') : Config::main('default_timezone');
            default:
                return $key;
        }
    }

    /**
     * Parsing record data.
     *
     * @param string $params
     * @param mixed  $isPermitted
     *
     * @return string
     */
    protected function record($params, $isPermitted = true)
    {
        if (!isset($this->recordModel) || ($isPermitted && !Privilege::isPermitted($this->moduleName, 'DetailView', $this->record))) {
            return '';
        }
        [$key, $params] = array_pad(explode('|', $params, 2), 2, false);
        if ($this->recordModel->has($key)) {
            $fieldModel = $this->recordModel->getModule()->getFieldByName($key);
            if (!$fieldModel || !$this->useValue($fieldModel, $this->moduleName)) {
                return '';
            }
            return $this->getDisplayValueByField($fieldModel, false, $params);
        }
        switch ($key) {
            case 'CrmDetailViewURL':
                return Config::main('site_URL') . 'index.php?module=' . $this->moduleName . '&view=Detail&record=' . $this->record;
            case 'PortalDetailViewURL':
                $recorIdName = 'id';
                if ('HelpDesk' === $this->moduleName) {
                    $recorIdName = 'ticketid';
                } elseif ('Faq' === $this->moduleName) {
                    $recorIdName = 'faqid';
                } elseif ('Products' === $this->moduleName) {
                    $recorIdName = 'productid';
                }
                return Config::main('PORTAL_URL') . '/index.php?module=' . $this->moduleName . '&action=index&' . $recorIdName . '=' . $this->record;
            case 'ModuleName':
                return $this->moduleName;
            case 'RecordId':
                return $this->record;
            case 'RecordLabel':
                return $this->recordModel->getName();
            case 'ChangesListChanges':
                $value = '';
                foreach ($this->recordModel->getPreviousValue() as $fieldName => $oldValue) {
                    $fieldModel = $this->recordModel->getModule()->getFieldByName($fieldName);
                    if (!$fieldModel) {
                        continue;
                    }
                    $oldValue = $this->getDisplayValueByField($fieldModel, $oldValue);
                    $currentValue = $this->getDisplayValueByField($fieldModel);
                    if ($this->withoutTranslations) {
                        $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                        $value .= "\$(translate : {$this->moduleName}|{$label})\$ \$(translate : LBL_FROM)\$ $oldValue \$(translate : LBL_TO)\$ " . $currentValue . ($this->isHtml ? '<br>' : PHP_EOL);
                    } else {
                        $value .= Language::translate($fieldModel->getFieldLabel(), $this->moduleName, $this->language) . ' ';
                        $value .= Language::translate('LBL_FROM') . " $oldValue " . Language::translate('LBL_TO') . " $currentValue" . ($this->isHtml ? '<br>' : PHP_EOL);
                    }
                }
                return $value;
            case 'ChangesListValues':
                $value = '';
                $changes = $this->recordModel->getPreviousValue();
                if (empty($changes)) {
                    $changes = array_filter($this->recordModel->getData());
                    unset($changes['createdtime'], $changes['modifiedtime'], $changes['id'], $changes['newRecord'], $changes['modifiedby']);
                }
                foreach ($changes as $fieldName => $oldValue) {
                    $fieldModel = $this->recordModel->getModule()->getFieldByName($fieldName);
                    if (!$fieldModel) {
                        continue;
                    }
                    $currentValue = \in_array($fieldModel->getFieldDataType(), $this->largeDataUiTypes) ? '' : $this->getDisplayValueByField($fieldModel);
                    if ($this->withoutTranslations) {
                        $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                        $value .= "\$(translate : {$this->moduleName}|{$label})\$: $currentValue" . ($this->isHtml ? '<br>' : PHP_EOL);
                    } else {
                        $value .= Language::translate($fieldModel->getFieldLabel(), $this->moduleName, $this->language) . ": $currentValue" . ($this->isHtml ? '<br>' : PHP_EOL);
                    }
                }
                return $value;
            case 'SummaryFields':
                    $value = '';
                    $recordStructure = \Vtiger_RecordStructure_Model::getInstanceFromRecordModel($this->recordModel, \Vtiger_RecordStructure_Model::RECORD_STRUCTURE_MODE_SUMMARY);
                    $fields = $recordStructure->getStructure()['SUMMARY_FIELDS'] ?? [];
                    foreach ($fields as $fieldName => $fieldModel) {
                        $currentValue = $this->getDisplayValueByField($fieldModel);
                        if ($this->withoutTranslations) {
                            $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                            $value .= "\$(translate : {$this->moduleName}|{$label})\$: $currentValue" . ($this->isHtml ? '<br>' : PHP_EOL);
                        } else {
                            $value .= Language::translate($fieldModel->getFieldLabel(), $this->moduleName, $this->language) . ": $currentValue" . ($this->isHtml ? '<br>' : PHP_EOL);
                        }
                    }
                    return $value;
            default:
                if (false !== strpos($key, ' ')) {
                    [$key, $params] = explode(' ', $key);
                }
                if ('Comments' === $key) {
                    return $this->getComments($params);
                }
                break;
        }
        return '';
    }

    /**
     * Parsing related record data.
     *
     * @param string $params
     *
     * @return mixed
     */
    protected function relatedRecord($params)
    {
        $params = explode('|', $params);
        $fieldName = array_shift($params);
        $relatedField = array_shift($params);
        $relatedModule = array_shift($params);
        $value = $params ? $relatedField . '|' . implode('|', $params) : $relatedField;
        if (
            !isset($this->recordModel)
            || ($this->permissions && !Privilege::isPermitted($this->moduleName, 'DetailView', $this->record))
            || $this->recordModel->isEmpty($fieldName)
        ) {
            return '';
        }
        $relatedId = $this->recordModel->get($fieldName);
        if (empty($relatedId)) {
            return '';
        }
        if (empty($relatedModule) && \in_array($this->recordModel->getField($fieldName)->getFieldDataType(), ['owner', 'sharedOwner'])) {
            $relatedModule = 'Users';
        }
        if ('Users' === $relatedModule) {
            $return = [];
            foreach (explode(',', $relatedId) as $relatedValueId) {
                if ('Users' === Fields\Owner::getType($relatedValueId)) {
                    $userRecordModel = \Vtiger_Record_Model::getInstanceById($relatedValueId, $relatedModule);
                    if ('Active' === $userRecordModel->get('status')) {
                        $instance = static::getInstanceByModel($userRecordModel);
                        foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
                            if (isset($this->{$key})) {
                                $instance->{$key} = $this->{$key};
                            }
                        }
                        $return[] = $instance->record($value, false);
                    }
                    continue;
                }
                foreach (PrivilegeUtil::getUsersByGroup($relatedValueId) as $userId) {
                    $userRecordModel = \Vtiger_Record_Model::getInstanceById($userId, $relatedModule);
                    if ('Active' === $userRecordModel->get('status')) {
                        $instance = static::getInstanceByModel($userRecordModel);
                        foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
                            if (isset($this->{$key})) {
                                $instance->{$key} = $this->{$key};
                            }
                        }
                        $return[] = $instance->record($value, false);
                    }
                }
            }
            return implode($this->relatedRecordSeparator, $return);
        }
        $module = Record::getType($relatedId);
        if (!Record::isExists($relatedId) || empty($module) || ($relatedModule && $relatedModule !== $module)) {
            return '';
        }
        $relatedRecordModel = \Vtiger_Record_Model::getInstanceById($relatedId, $module);
        if ($this->permissions && !$relatedRecordModel->isViewable()) {
            return '';
        }
        $instance = static::getInstanceByModel($relatedRecordModel);
        foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
            if (isset($this->{$key})) {
                $instance->{$key} = $this->{$key};
            }
        }
        return $instance->record($value);
    }

    /**
     * Parsing related record data.
     *
     * @param string $params
     *
     * @return mixed
     */
    protected function relatedRecordLevel($params)
    {
        [$fieldName, $relatedModule, $relatedRecord] = array_pad(explode('|', $params, 3), 3, '');
        if (
            !isset($this->recordModel)
            || !Privilege::isPermitted($this->moduleName, 'DetailView', $this->record)
            || $this->recordModel->isEmpty($fieldName)
        ) {
            return '';
        }
        $relatedId = $this->recordModel->get($fieldName);
        if (empty($relatedId)) {
            return '';
        }
        $moduleName = Record::getType($relatedId);
        if (!empty($moduleName) && ($relatedModule && $relatedModule !== $moduleName)) {
            return '';
        }
        if ('Users' === $relatedModule && 'Users' === Fields\Owner::getType($relatedId)) {
            $relatedRecordModel = \Users_Privileges_Model::getInstanceById($relatedId);
            if ('Active' !== $relatedRecordModel->get('status')) {
                return '';
            }
        } else {
            $relatedRecordModel = \Vtiger_Record_Model::getInstanceById($relatedId, $moduleName);
            if (!$relatedRecordModel->isViewable()) {
                return '';
            }
        }
        $instance = static::getInstanceByModel($relatedRecordModel);
        foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
            if (isset($this->{$key})) {
                $instance->{$key} = $this->{$key};
            }
        }
        return $instance->relatedRecord($relatedRecord);
    }

    /**
     * Parsing source record data.
     *
     * @param string $fieldName
     *
     * @return mixed
     */
    protected function sourceRecord($fieldName)
    {
        if (empty($this->sourceRecordModel) || !Privilege::isPermitted($this->sourceRecordModel->getModuleName(), 'DetailView', $this->sourceRecordModel->getId())) {
            return '';
        }
        $instance = static::getInstanceByModel($this->sourceRecordModel);
        foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
            if (isset($this->{$key})) {
                $instance->{$key} = $this->{$key};
            }
        }
        return $instance->record($fieldName);
    }

    /**
     * Parsing related records list.
     *
     * @param string $params Parameter construction: RelatedModuleNameOrRelationId|Columns|Conditions|CustomViewIdOrName|Limit, Example: Contacts|firstname,lastname,modifiedtime|[[["firstname","a","Tom"]]]||2
     *
     * @return string
     */
    protected function relatedRecordsList($params)
    {
        [$relatedModuleName, $columns, $conditions, $viewIdOrName, $limit, $maxLength] = array_pad(explode('|', $params), 6, '');
        if (is_numeric($relatedModuleName)) {
            if ($relationListView = \Vtiger_RelationListView_Model::getInstance($this->recordModel, '', $relatedModuleName)) {
                $relatedModuleName = $relationListView->getRelatedModuleModel()->getName();
            }
        } else {
            $relationListView = \Vtiger_RelationListView_Model::getInstance($this->recordModel, $relatedModuleName);
        }
        if (!$relationListView || !Privilege::isPermitted($relatedModuleName)) {
            return '';
        }
        $pagingModel = new \Vtiger_Paging_Model();
        $pagingModel->set('limit', (int) $limit);
        if ($viewIdOrName) {
            if (!is_numeric($viewIdOrName)) {
                $customView = CustomView::getInstance($relatedModuleName);
                if ($cvId = $customView->getViewIdByName($viewIdOrName)) {
                    $viewIdOrName = $cvId;
                } else {
                    $viewIdOrName = false;
                    Log::warning("No view found. Module: $relatedModuleName, view name: $viewIdOrName", 'TextParser');
                }
            }
            if ($viewIdOrName) {
                $relationListView->getQueryGenerator()->initForCustomViewById($viewIdOrName);
            }
            if ($cvId && ($customViewModel = \CustomView_Record_Model::getInstanceById($cvId)) && ($orderBy = $customViewModel->getSortOrderBy())) {
                $relationListView->set('orderby', $orderBy);
            }
        }
        if ($columns) {
            $relationListView->setFields($columns);
        } else {
            $fields = array_filter($relationListView->getHeaders(), fn ($fieldModel) => !$fieldModel->get('fromOutsideList'));
            $relationListView->setFields(array_keys($fields));
        }
        if ($conditions) {
            $transformedSearchParams = $relationListView->getQueryGenerator()->parseBaseSearchParamsToCondition(Json::decode($conditions));
            $relationListView->set('search_params', $transformedSearchParams);
        }
        return $this->relatedRecordsListPrinter($relationListView, $pagingModel, (int) $maxLength);
    }

    /**
     * Printer related records list.
     *
     * @param \Vtiger_RelationListView_Model $relationListView
     * @param \Vtiger_Paging_Model           $pagingModel
     * @param int                            $maxLength
     *
     * @return string
     */
    protected function relatedRecordsListPrinter(\Vtiger_RelationListView_Model $relationListView, \Vtiger_Paging_Model $pagingModel, int $maxLength): string
    {
        $relatedModuleName = $relationListView->getRelationModel()->getRelationModuleName();
        $rows = $headers = '';
        $fields = $relationListView->getRelationModel()->getQueryFields();
        foreach ($fields as $fieldModel) {
            if ($fieldModel->isViewable() || $fieldModel->get('fromOutsideList')) {
                if ($this->withoutTranslations) {
                    $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                    $headers .= "<th class=\"col-type-{$fieldModel->getFieldType()}\">$(translate : {$label}|$relatedModuleName)$</th>";
                } else {
                    $headers .= "<th class=\"col-type-{$fieldModel->getFieldType()}\">" . Language::translate($fieldModel->getFieldLabel(), $relatedModuleName) . '</th>';
                }
            }
        }
        $counter = 0;
        foreach ($relationListView->getEntries($pagingModel) as $relatedRecordModel) {
            ++$counter;
            $rows .= '<tr class="row-' . $counter . '">';
            foreach ($fields as $fieldModel) {
                $value = $this->getDisplayValueByField($fieldModel, $relatedRecordModel);
                if (false !== $value) {
                    if ($maxLength) {
                        $value = TextUtils::textTruncate($value, $maxLength);
                    }
                    $rows .= "<td class=\"col-type-{$fieldModel->getFieldType()}\">{$value}</td>";
                }
            }
            $rows .= '</tr>';
        }
        return empty($rows) ? '' : "<table style=\"border-collapse:collapse;width:100%\" class=\"related-records-list\"><thead><tr>{$headers}</tr></thead><tbody>{$rows}</tbody></table>";
    }

    /**
     * Parsing records list.
     *
     * @param string $params Parameter construction: ModuleName|Columns|Conditions|CustomViewIdOrName|Limit, Example: Contacts|firstname,lastname,modifiedtime|[[["firstname","a","Tom"]]]||2
     *
     * @return string
     */
    protected function recordsList($params)
    {
        [$moduleName, $columns, $conditions, $viewIdOrName, $limit, $maxLength, $params] = array_pad(explode('|', $params, 7), 7, '');
        $paramsArray = $params ? self::parseFieldParam($params) : [];
        $cvId = 0;
        if ($viewIdOrName) {
            if (!is_numeric($viewIdOrName)) {
                $customView = CustomView::getInstance($moduleName);
                if ($cvIdByName = $customView->getViewIdByName($viewIdOrName)) {
                    $viewIdOrName = $cvIdByName;
                } else {
                    $viewIdOrName = false;
                    Log::warning("No view found. Module: $moduleName, view name: $viewIdOrName", 'TextParser');
                }
            }
            if ($viewIdOrName) {
                $cvId = $viewIdOrName;
            }
        }
        $listView = \Vtiger_ListView_Model::getInstance($moduleName, $cvId);
        if ($cvId && ($customViewModel = \CustomView_Record_Model::getInstanceById($cvId)) && ($orderBy = $customViewModel->getSortOrderBy())) {
            $listView->set('orderby', $orderBy);
        }
        $limit = (int) $limit;
        $listView->getQueryGenerator()->setLimit((int) ($limit ?: \App\Config::main('list_max_entries_per_page', 20)));
        if ($columns) {
            $headerFields = [];
            foreach (explode(',', $columns) as $fieldName) {
                $headerFields[] = [
                    'field_name' => $fieldName,
                    'module_name' => $moduleName,
                ];
            }
            $listView->set('header_fields', $headerFields);
            $listView->getQueryGenerator()->setFields(explode(',', $columns));
            $listView->getQueryGenerator()->setField('id');
        }
        if ($conditions) {
            $transformedSearchParams = $listView->getQueryGenerator()->parseBaseSearchParamsToCondition(Json::decode($conditions));
            $listView->set('search_params', $transformedSearchParams);
        }
        if (($pdf = $this->getParam('pdf')) && $pdf->get('module_name') === $moduleName && ($ids = $pdf->getVariable('recordsId'))) {
            $listView->getQueryGenerator()->addCondition('id', $ids, 'e', 1);
        }
        $rows = $headers = $headerStyle = $borderStyle = '';
        $fields = $listView->getListViewHeaders();
        if (isset($paramsArray['headerStyle']) && 'background' === $paramsArray['headerStyle']) {
            $headerStyle = 'background-color:#ddd;';
        }
        if (isset($paramsArray['table']) && 'border' === $paramsArray['table']) {
            $borderStyle = 'border:1px solid  #ddd;';
        }
        foreach ($fields as $fieldModel) {
            if ($this->withoutTranslations) {
                $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                $headers .= "<th class=\"col-type-{$fieldModel->getFieldType()}\" style=\"{$headerStyle}\">$(translate : {$label}|$moduleName)$</th>";
            } else {
                $headers .= "<th class=\"col-type-{$fieldModel->getFieldType()}\" style=\"{$headerStyle}\">" . Language::translate($fieldModel->getFieldLabel(), $moduleName) . '</th>';
            }
        }
        $counter = 0;
        foreach ($listView->getAllEntries() as $relatedRecordModel) {
            ++$counter;
            $rows .= '<tr class="row-' . $counter . '">';
            foreach ($fields as $fieldModel) {
                $value = $this->getDisplayValueByField($fieldModel, $relatedRecordModel, $params);
                if (false !== $value) {
                    if ((int) $maxLength) {
                        $value = TextUtils::textTruncate($value, (int) $maxLength);
                    }
                    $rows .= "<td class=\"col-type-{$fieldModel->getFieldType()}\" style=\"{$borderStyle}\">{$value}</td>";
                }
            }
            $rows .= '</tr>';
        }
        if (empty($rows)) {
            return '';
        }
        $headers = "<tr>{$headers}</tr>";
        $table = "class=\"records-list\" style=\"border-collapse:collapse;width:100%;{$borderStyle}\"";
        if (isset($paramsArray['addCounter']) && '1' === $paramsArray['addCounter']) {
            $headers = '<tr><th colspan="' . \count($fields) . '">' . Language::translate('LBL_NUMBER_OF_ALL_ENTRIES') . ": $counter</th></th></tr>$headers";
        }
        return "<table {$table}><thead>{$headers}</thead><tbody>{$rows}</tbody></table>";
    }

    /**
     * Get record display value.
     *
     * @param \Vtiger_Field_Model             $fieldModel
     * @param bool|mixed|\Vtiger_Record_Model $value
     * @param string                          $params
     *
     * @return array|bool|mixed|string
     */
    protected function getDisplayValueByField(\Vtiger_Field_Model $fieldModel, $value = false, $params = null)
    {
        $model = $this->recordModel;
        if (false === $value) {
            $value = \App\Utils\Completions::decode($this->recordModel->get($fieldModel->getName()), \App\Utils\Completions::FORMAT_TEXT);
            if (!$fieldModel->isViewEnabled() && !$fieldModel->get('fromOutsideList')) {
                return '';
            }
        } elseif (\is_object($value)) {
            $model = $value;
            $value = $value->get($fieldModel->getName());
            if (!$fieldModel->isViewEnabled() && !$fieldModel->get('fromOutsideList')) {
                return false;
            }
        }
        if ('' === $value) {
            return '';
        }
        if ($this->withoutTranslations) {
            return $this->getDisplayValueByType($value, $model, $fieldModel, $params);
        }
        return $fieldModel->getUITypeModel()->getTextParserDisplayValue($value, $model, $params);
    }

    /**
     * Get record display value by type.
     *
     * @param mixed                $value
     * @param \Vtiger_Record_Model $recordModel
     * @param \Vtiger_Field_Model  $fieldModel
     * @param string               $params
     *
     * @return array|mixed|string
     */
    protected function getDisplayValueByType($value, \Vtiger_Record_Model $recordModel, \Vtiger_Field_Model $fieldModel, $params)
    {
        switch ($fieldModel->getFieldDataType()) {
            case 'boolean':
                $value = (1 === $value) ? 'LBL_YES' : 'LBL_NO';
                $value = "$(translate : $value)$";
                break;
            case 'multipicklist':
                $value = explode(' |##| ', $value);
                $trValue = [];
                $countValue = \count($value);
                for ($i = 0; $i < $countValue; ++$i) {
                    $trValue[] = "$(translate : {$recordModel->getModuleName()}|{$value[$i]})$";
                }
                if (\is_array($trValue)) {
                    $trValue = implode(' |##| ', $trValue);
                }
                $value = str_ireplace(' |##| ', ', ', $trValue);
                break;
            case 'picklist':
                $value = "$(translate : {$recordModel->getModuleName()}|$value)$";
                break;
            case 'time':
                $userModel = \Users_Privileges_Model::getCurrentUserModel();
                $value = \DateTimeField::convertToUserTimeZone(date('Y-m-d') . ' ' . $value)->format('H:i:s');
                if (12 === (int) $userModel->get('hour_format')) {
                    if ($value) {
                        [$hours, $minutes] = array_pad(explode(':', $value), 2, '');
                        $format = '$(translate : PM)$';
                        if ($hours > 12) {
                            $hours = (int) $hours - 12;
                        } elseif ($hours < 12) {
                            $format = '$(translate : AM)$';
                        }
                        //If hours zero then we need to make it as 12 AM
                        if ('00' == $hours) {
                            $hours = '12';
                            $format = '$(translate : AM)$';
                        }
                        $value = "$hours:$minutes $format";
                    } else {
                        $value = '';
                    }
                }
                break;
            case 'tree':
                $template = $fieldModel->getFieldParams();
                $value = $parentName = '';
                if ($row = Fields\Tree::getValueByTreeId($template, $value)) {
                    if ($row['depth'] > 0) {
                        $pieces = explode('::', $row['parentTree']);
                        end($pieces);
                        $parent = prev($pieces);
                        $parentRow = Fields\Tree::getValueByTreeId($template, $parent);
                        $parentName = "($(translate : {$recordModel->getModuleName()}|{$parentRow['name']})$) ";
                    }
                    $value = $parentName . "$(translate : {$recordModel->getModuleName()}|{$row['name']})$";
                }
                break;
            default:
                return $fieldModel->getUITypeModel()->getTextParserDisplayValue($value, $recordModel, $params);
        }
        return $value;
    }

    /**
     * Get last comments.
     *
     * @param mixed $params
     *
     * @return string
     */
    protected function getComments($params = false)
    {
        [$limit, $showAuthor] = array_pad(explode('|', $params, 2), 2, false);
        $query = (new \App\Db\Query())->select(['commentcontent', 'userid'])->from('vtiger_modcomments')->where(['related_to' => $this->record])->orderBy(['modcommentsid' => SORT_DESC]);
        if ($limit) {
            $query->limit($limit);
        }
        $commentsList = '';
        foreach ($query->all() as $comment) {
            if ('' != $comment['commentcontent']) {
                $commentsList .= '<br><br>';
                if ('true' === $showAuthor) {
                    $commentsList .= Purifier::encodeHtml(\App\Fields\Owner::getUserLabel($comment['userid'])) . ': ';
                }
                $commentsList .= nl2br($comment['commentcontent']);
            }
        }
        return ltrim($commentsList, '<br><br>');
    }

    /**
     * Check if this content can be used.
     *
     * @param \Vtiger_Field_Model $fieldModel
     * @param string              $moduleName
     *
     * @return bool
     */
    protected function useValue($fieldModel, $moduleName)
    {
        return true;
    }

    /**
     * Parsing params.
     *
     * @param string $key
     *
     * @return string
     */
    protected function params(string $key)
    {
        return isset($this->params[$key]) ? \App\Purifier::purifyHtml($this->params[$key]) : '';
    }

    /**
     * Parsing custom.
     *
     * @param string $params
     *
     * @return string
     */
    protected function custom($params)
    {
        $instance = null;
        if (false !== strpos($params, '||')) {
            $params = explode('||', $params);
            $parserName = array_shift($params);
            $baseParams = $params;
            $params = [];
        } else {
            $params = explode('|', $params);
            $parserName = array_shift($params);
            $baseParams = $params;
        }
        $module = false;
        if (!empty($params)) {
            $module = array_shift($params);
            if (!Module::getModuleId($module)) {
                $module = $this->moduleName;
            }
        }
        $className = "\\App\\TextParser\\$parserName";
        if ($module && $handlerClass = \Vtiger_Loader::getComponentClassName('TextParser', $parserName, $module, false)) {
            $className = $handlerClass;
        }
        if (!class_exists($className)) {
            Log::error("Not found custom class: $parserName|{$module}");
        } else {
            $instance = new $className($this, $baseParams);
        }
        return $instance && $instance->isActive() ? $instance->process() : '';
    }

    /**
     * Get record variables.
     *
     * @param bool|string $fieldType
     *
     * @return array
     */
    public function getRecordVariable($fieldType = false)
    {
        $cacheKey = "{$this->moduleName}|$fieldType";
        if (isset(static::$recordVariable[$cacheKey])) {
            return static::$recordVariable[$cacheKey];
        }
        $variables = [];
        if (!$fieldType) {
            foreach (static::$variableEntity as $key => $name) {
                $variables[Language::translate('LBL_ENTITY_VARIABLES', 'Other.TextParser')][] = [
                    'var_value' => "$(record : $key)$",
                    'var_label' => "$(translate : Other.TextParser|$name)$",
                    'label' => Language::translate($name, 'Other.TextParser'),
                ];
            }
        }
        $moduleModel = \Vtiger_Module_Model::getInstance($this->moduleName);
        foreach ($moduleModel->getBlocks() as $blockModel) {
            foreach ($blockModel->getFields() as $fieldModel) {
                if ($fieldModel->isViewable() && !($fieldType && $fieldModel->getFieldDataType() !== $fieldType)) {
                    $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                    $variables[Language::translate($blockModel->get('label'), $this->moduleName)][] = [
                        'var_value' => "$(record : {$fieldModel->getName()})$",
                        'var_label' => "$(translate : {$this->moduleName}|{$label})$",
                        'label' => Language::translate($fieldModel->getFieldLabel(), $this->moduleName),
                    ];
                }
            }
        }
        static::$recordVariable[$cacheKey] = $variables;
        return $variables;
    }

    /**
     * Get source variables.
     *
     * @return array
     */
    public function getSourceVariable()
    {
        if (empty(self::$sourceModules[$this->moduleName])) {
            return false;
        }
        $variables = [];
        foreach (static::$variableEntity as $key => $name) {
            $variables['LBL_ENTITY_VARIABLES'][] = [
                'var_value' => "$(sourceRecord : $key)$",
                'var_label' => "$(translate : Other.TextParser|$name)$",
                'label' => Language::translate($name, 'Other.TextParser'),
            ];
        }
        foreach (self::$sourceModules[$this->moduleName] as $moduleName) {
            $moduleModel = \Vtiger_Module_Model::getInstance($moduleName);
            foreach ($moduleModel->getBlocks() as $blockModel) {
                foreach ($blockModel->getFields() as $fieldModel) {
                    if ($fieldModel->isViewable()) {
                        $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                        $variables[$moduleName][$blockModel->get('label')][] = [
                            'var_value' => "$(sourceRecord : {$fieldModel->getName()})$",
                            'var_label' => "$(translate : $moduleName|{$label})$",
                            'label' => Language::translate($fieldModel->getFieldLabel(), $moduleName),
                        ];
                    }
                }
            }
        }
        return $variables;
    }

    /**
     * Get related variables.
     *
     * @param bool|string $fieldType
     * @param bool        $skipEmpty
     *
     * @return array
     */
    public function getRelatedVariable($fieldType = false, $skipEmpty = false)
    {
        $cacheKey = "{$this->moduleName}|$fieldType|{$skipEmpty}";
        if (isset(static::$relatedVariable[$cacheKey])) {
            return static::$relatedVariable[$cacheKey];
        }
        $moduleModel = \Vtiger_Module_Model::getInstance($this->moduleName);
        $variables = [];
        $entityVariables = Language::translate('LBL_ENTITY_VARIABLES', 'Other.TextParser');
        foreach ($moduleModel->getFieldsByType(array_merge(\Vtiger_Field_Model::$referenceTypes, ['userCreator', 'owner', 'sharedOwner'])) as $parentFieldName => $field) {
            if ('owner' === $field->getFieldDataType() || 'sharedOwner' === $field->getFieldDataType()) {
                $relatedModules = ['Users'];
            } else {
                $relatedModules = $field->getReferenceList();
            }
            $parentFieldNameLabel = Language::translate($field->getFieldLabel(), $this->moduleName);
            if (!$fieldType) {
                foreach (static::$variableEntity as $key => $name) {
                    $variables[$parentFieldName]["$parentFieldNameLabel - $entityVariables"][] = [
                        'var_value' => "$(relatedRecord : $parentFieldName|$key)$",
                        'var_label' => "$(translate : Other.TextParser|$key)$",
                        'label' => $parentFieldNameLabel . ': ' . Language::translate($name, 'Other.TextParser'),
                    ];
                }
            }
            $relRecord = false;
            if ($skipEmpty && $this->recordModel && !(($relId = $this->recordModel->get($field->getName()))
                && (
                    \in_array($field->getFieldDataType(), ['userCreator', 'owner', 'sharedOwner'])
                    || ((Record::isExists($relId)) && ($relRecord = \Vtiger_Record_Model::getInstanceById($relId))->isViewable() && ($relatedModules = [Record::getType($relId)]))
                )
            )) {
                continue;
            }

            foreach ($relatedModules as $relatedModule) {
                $relatedModuleLang = Language::translate($relatedModule, $relatedModule);
                foreach (\Vtiger_Module_Model::getInstance($relatedModule)->getBlocks() as $blockModel) {
                    foreach ($blockModel->getFields() as $fieldName => $fieldModel) {
                        if (
                            $fieldModel->isViewable()
                            && !($fieldType && $fieldModel->getFieldDataType() !== $fieldType)
                            && (!$relRecord || ($relRecord && !$relRecord->isEmpty($fieldModel->getName())))
                        ) {
                            $labelGroup = "$parentFieldNameLabel: ($relatedModuleLang) " . Language::translate($blockModel->get('label'), $relatedModule);
                            $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                            $variables[$parentFieldName][$labelGroup][] = [
                                'var_value' => "$(relatedRecord : $parentFieldName|$fieldName|$relatedModule)$",
                                'var_label' => "$(translate : $relatedModule|{$label})$",
                                'label' => "$parentFieldNameLabel: ($relatedModuleLang) " . Language::translate($fieldModel->getFieldLabel(), $relatedModule),
                            ];
                        }
                    }
                }
            }
        }
        static::$relatedVariable[$cacheKey] = $variables;
        return $variables;
    }

    /**
     * Get related variables.
     *
     * @param bool|string $fieldType
     *
     * @return array
     */
    public function getRelatedLevelVariable($fieldType = false)
    {
        $cacheKey = "{$this->moduleName}|$fieldType";
        if (isset(static::$relatedVariableLevel[$cacheKey])) {
            return static::$relatedVariableLevel[$cacheKey];
        }
        $moduleModel = \Vtiger_Module_Model::getInstance($this->moduleName);
        $variables = [];
        foreach ($moduleModel->getFieldsByType(array_merge(\Vtiger_Field_Model::$referenceTypes, ['userCreator', 'owner'])) as $parentFieldName => $fieldModel) {
            if ('owner' === $fieldModel->getFieldDataType()) {
                $relatedModules = ['Users'];
            } else {
                $relatedModules = $fieldModel->getReferenceList();
            }
            $parentFieldNameLabel = Language::translate($fieldModel->getFieldLabel(), $this->moduleName);
            foreach ($relatedModules as $relatedModule) {
                $relatedModuleLang = Language::translate($relatedModule, $relatedModule);
                foreach (\Vtiger_Module_Model::getInstance($relatedModule)->getFieldsByType(array_merge(\Vtiger_Field_Model::$referenceTypes, ['userCreator', 'owner', 'sharedOwner'])) as $parentFieldNameNextLevel => $fieldModelNextLevel) {
                    if ('owner' === $fieldModelNextLevel->getFieldDataType() || 'sharedOwner' === $fieldModelNextLevel->getFieldDataType()) {
                        $relatedModulesNextLevel = ['Users'];
                    } else {
                        $relatedModulesNextLevel = $fieldModelNextLevel->getReferenceList();
                    }
                    $parentFieldNameLabelNextLevel = Language::translate($fieldModelNextLevel->getFieldLabel(), $relatedModule);
                    foreach ($relatedModulesNextLevel as $relatedModuleNextLevel) {
                        $relatedModuleLangNextLevel = Language::translate($relatedModuleNextLevel, $relatedModuleNextLevel);
                        foreach (\Vtiger_Module_Model::getInstance($relatedModuleNextLevel)->getBlocks() as $blockModel) {
                            foreach ($blockModel->getFields() as $fieldName => $fieldModel) {
                                if ($fieldModel->isViewable() && !($fieldType && $fieldModel->getFieldDataType() !== $fieldType)) {
                                    $labelGroup = "{$parentFieldNameLabel}($relatedModuleLang) -> {$parentFieldNameLabelNextLevel}($relatedModuleLangNextLevel) " . Language::translate($blockModel->get('label'), $relatedModuleNextLevel);
                                    $label = \App\Purifier::encodeHtml($fieldModel->getFieldLabel());
                                    $variables[$labelGroup][] = [
                                        'var_value' => "$(relatedRecordLevel : $parentFieldName|$relatedModule|$parentFieldNameNextLevel|$fieldName|$relatedModuleNextLevel)$",
                                        'var_label' => "$(translate : $relatedModuleNextLevel|{$label})$",
                                        'label' => "{$parentFieldNameLabel}($relatedModuleLang) -> {$parentFieldNameLabelNextLevel}($relatedModuleLangNextLevel) " . Language::translate($fieldModel->getFieldLabel(), $relatedModuleNextLevel),
                                    ];
                                }
                            }
                        }
                    }
                }
            }
        }
        static::$relatedVariableLevel[$cacheKey] = $variables;
        return $variables;
    }

    /**
     * Get general variables.
     *
     * @return array
     */
    public function getGeneralVariable()
    {
        $variables = [
            'LBL_ENTITY_VARIABLES' => array_map(fn ($value) => Language::translate($value, 'Other.TextParser'), array_flip(static::$variableGeneral)),
        ];
        $variables['LBL_CUSTOM_VARIABLES'] = array_merge($this->getBaseGeneralVariable(), $this->getModuleGeneralVariable());
        return $variables;
    }

    /**
     * Get general variables base function.
     *
     * @return array
     */
    protected function getBaseGeneralVariable()
    {
        $variables = [];
        foreach ((new \DirectoryIterator(__DIR__ . \DIRECTORY_SEPARATOR . 'TextParser')) as $fileInfo) {
            $fileName = $fileInfo->getBasename('.php');
            if ('dir' !== $fileInfo->getType() && 'Base' !== $fileName && 'php' === $fileInfo->getExtension()) {
                $className = '\App\TextParser\\' . $fileName;
                if (!class_exists($className)) {
                    Log::warning('Not found custom class');
                    continue;
                }
                $instance = new $className($this);
                if (isset($this->type) && $this->type !== $instance->type) {
                    continue;
                }
                $key = $instance->default ?? "$(custom : $fileName)$";
                $variables[$key] = Language::translate($instance->name, 'Other.TextParser');
            }
        }
        return $variables;
    }

    /**
     * Get general variables module function.
     *
     * @return array
     */
    protected function getModuleGeneralVariable()
    {
        $variables = [];
        if ($this->moduleName && is_dir(("modules/{$this->moduleName}/textparsers/"))) {
            foreach ((new \DirectoryIterator("modules/{$this->moduleName}/textparsers/")) as $fileInfo) {
                $fileName = $fileInfo->getBasename('.php');
                if ('dir' !== $fileInfo->getType() && 'php' === $fileInfo->getExtension()) {
                    $handlerClass = \Vtiger_Loader::getComponentClassName('TextParser', $fileName, $this->moduleName);
                    $instanceClass = new $handlerClass($this);
                    if (isset($this->type) && $this->type !== $instanceClass->type) {
                        continue;
                    }
                    $variables["$(custom : $fileName|{$this->moduleName})$"] = Language::translate($instanceClass->name, $this->moduleName);
                }
            }
        }
        return $variables;
    }

    /**
     * Get related modules list.
     *
     * @return array
     */
    public function getRelatedListVariable()
    {
        $moduleModel = \Vtiger_Module_Model::getInstance($this->moduleName);
        $variables = [];
        foreach ($moduleModel->getRelations() as $relation) {
            $var = $relation->get('relatedModuleName');
            if ($relation->get('field_name')) {
                $var = $relation->get('relation_id');
            }
            $variables[] = [
                'key' => "$(relatedRecordsList : $var|__FIELDS_NAME__|__CONDITIONS__|__VIEW_ID_OR_NAME__|__LIMIT__|__MAX_LENGTH__)$",
                'label' => Language::translate($relation->get('label'), $relation->get('relatedModuleName')),
            ];
        }
        return $variables;
    }

    /**
     * Get base modules list.
     *
     * @return array
     */
    public function getBaseListVariable()
    {
        $variables = [];
        foreach (\vtlib\Functions::getAllModules() as $module) {
            $variables[] = [
                'key' => "$(recordsList : {$module['name']})$",
                'label' => Language::translate($module['name'], $module['name']),
            ];
        }
        return $variables;
    }

    /**
     * Gets user variables.
     *
     * @param string $text
     * @param bool   $useRegex
     *
     * @return array
     */
    public function getUserVariables(string $text, bool $useRegex = true)
    {
        $data = [];
        if ($useRegex) {
            preg_match_all('/\$\(userVariable : ([,"\+\%\.\=\-\[\]\&\w\s\|\)\(\:]+)\)\$/u', str_replace(['%20%3A%20', '%20:%20'], ' : ', $text), $matches);
            $matches = $matches[1] ?? [];
        } else {
            $matches = [$text];
        }
        foreach ($matches as $param) {
            $part = self::parseFieldParam($param);
            if (!empty($part['name']) && !(isset($data[$part['name']]))) {
                $data[$part['name']] = $part;
            }
        }
        return $data;
    }

    /**
     * Parsing user variable.
     *
     * @param string $params
     *
     * @return string
     */
    protected function userVariable($params)
    {
        $instance = null;
        $className = '\\App\\TextParser\\' . ucfirst(__FUNCTION__);
        if (!class_exists($className)) {
            Log::error("Not found custom class: $className");
        } else {
            $instance = new $className($this, $params);
        }
        return $instance && $instance->isActive() ? $instance->process() : '';
    }

    /**
     * Parsing inventory.
     *
     * @param string $params
     *
     * @return string
     */
    protected function inventory($params)
    {
        if (!$this->recordModel->getModule()->isInventory()) {
            return '';
        }
        $config = $this->parseParams($params);
        if ('table' === $config['type']) {
            return $this->getInventoryTable($config);
        }
        return '';
    }

    /**
     * Get an instance of barcode text parser.
     *
     * @param string $params
     *
     * @return string
     */
    protected function barcode($params): string
    {
        $params = $this->parseParams($params);
        if (isset($params['value'])) {
            $valueForParse = $params['value'];
        }
        if (isset($params['fieldName'])) {
            $valueForParse = $this->recordModel->get($params['fieldName']);
        }
        if ($valueForParse) {
            $className = '\Milon\Barcode\\' . $params['class'];
            if (!class_exists($className)) {
                throw new \App\Exceptions\AppException('ERR_CLASS_NOT_FOUND||' . $className);
            }
            $qrCodeGenerator = new $className();
            $qrCodeGenerator->setStorPath(__DIR__ . Config::main('tmp_dir'));
            $barcodeHeight = $this->params['height'] ?? 2;
            $barcodeWidth = $this->params['width'] ?? 30;
            $barcodeType = $this->params['type'] ?? 'EAN13';
            $showText = $this->params['showText'] ?? true;
            $png = $qrCodeGenerator->getBarcodePNG($valueForParse, $barcodeType, $barcodeHeight, $barcodeWidth, [0, 0, 0], $showText);
            return '<img src="data:image/png;base64,' . $png . '"/>';
        }
        return '';
    }

    /**
     * Get inventory param.
     *
     * @param string $params
     *
     * @return array
     */
    protected function parseParams(string $params): array
    {
        preg_match('/type=(\w+)/', $params, $matches);
        $config = [
            'type' => ($matches[1] ?? false),
        ];
        $params = ltrim($params, $matches[0] . ' ');
        foreach (explode(' , ', $params) as $value) {
            parse_str($value, $row);
            $config += $row;
        }
        if (isset($config['columns'])) {
            $config['columns'] = explode(',', $config['columns']);
        }
        return $config;
    }

    /**
     * Parsing inventory table.
     *
     * @param array $config
     *
     * @return string
     */
    public function getInventoryTable(array $config): string
    {
        $rawText = empty($config['href']) || 'yes' !== $config['href'];
        $inventory = \Vtiger_Inventory_Model::getInstance($this->moduleName);
        $fields = $inventory->getFieldsByBlocks();
        $inventoryRows = $this->recordModel->getInventoryData();
        $baseCurrency = \Vtiger_Util_Helper::getBaseCurrency();
        $firstRow = current($inventoryRows);
        if ($inventory->isField('currency')) {
            if (!empty($firstRow) && null !== $firstRow['currency']) {
                $currency = $firstRow['currency'];
            } else {
                $currency = $baseCurrency['id'];
            }
            $currencyData = \App\Fields\Currency::getById($currency);
            $currencySymbol = $currencyData['currency_symbol'];
        }
        $html = '';
        if (!empty($fields[1])) {
            $fieldsTextAlignRight = ['Unit', 'TotalPrice', 'Tax', 'MarginP', 'Margin', 'Purchase', 'Discount', 'NetPrice', 'GrossPrice', 'UnitPrice', 'Quantity', 'TaxPercent'];
            $fieldsWithCurrency = ['TotalPrice', 'Purchase', 'NetPrice', 'GrossPrice', 'UnitPrice', 'Discount', 'Margin', 'Tax'];
            $html .= '<table class="inventory-table" style="border-collapse:collapse;width:100%"><thead><tr>';
            $columns = [];
            $customFieldClassSeq = 0;
            $labels = isset($config['labels']) ? explode(',', $config['labels']) : [];
            $width = isset($config['width']) ? preg_replace('/[^[:alnum:]]/', '', explode(',', $config['width'])) : [];
            foreach ($config['columns'] as $key => $name) {
                if (false !== strpos($name, '||')) {
                    [$title,$value] = explode('||', $name, 2);
                    if ('(' === $title[0] && ')' === substr($title, -1)) {
                        $title = $this->parseVariable("\${$title}\$");
                    }
                    ++$customFieldClassSeq;
                    $html .= '<th class="col-type-customField' . $customFieldClassSeq . '" style="border:1px solid #ddd">' . $title . '</th>';
                    $columns[$title] = $value;
                    continue;
                }
                if ('seq' === $name) {
                    $html .= '<th class="col-type-ItemNumber" style="border:1px solid #ddd">' . Language::translate('LBL_ITEM_NUMBER', $this->moduleName) . '</th>';
                    $columns[$name] = false;
                    continue;
                }
                if (empty($fields[1][$name]) && empty($fields[2][$name])) {
                    continue;
                }
                $field = $fields[1][$name] ?? $fields[2][$name];
                if (!$field->isVisible()) {
                    continue;
                }
                $html .= '<th class="col-type-' . $field->getType() . '" style="border:1px solid #ddd ' . (empty($width[$key]) ? '' : ";width: {$width[$key]}") . '">' . (empty($labels[$key]) ? Language::translate($field->get('label'), $this->moduleName) : Purifier::encodeHtml($labels[$key])) . '</th>';
                $columns[$field->getColumnName()] = $field;
            }
            $html .= '</tr></thead><tbody>';
            $counter = 0;
            foreach ($inventoryRows as $inventoryRow) {
                ++$counter;
                $html .= '<tr class="row-' . $counter . '">';
                $customFieldClassSeq = 0;
                foreach ($columns as $name => $field) {
                    if ('seq' === $name) {
                        $html .= '<td class="col-type-ItemNumber" style="border:1px solid #ddd;font-weight:bold;">' . $counter . '</td>';
                    } elseif (!\is_object($field)) {
                        if ('(' === $field[0] && ')' === substr($field, -1)) {
                            $field = $this->parseVariable("\${$field}\$", $inventoryRow['name'] ?? 0);
                        }
                        ++$customFieldClassSeq;
                        $html .= '<td class="col-type-customField' . $customFieldClassSeq . '" style="border:1px solid #ddd;font-weight:bold;">' . $field . '</td>';
                    } elseif ('ItemNumber' === $field->getType()) {
                        $html .= '<td class="col-type-ItemNumber" style="border:1px solid #ddd;font-weight:bold;">' . $counter . '</td>';
                    } elseif ('ean' === $name) {
                        $itemValue = $inventoryRow[$name];
                        $html .= '<td class="col-type-barcode" style="border:1px solid #ddd;padding:0px 4px;' . (\in_array($field->getType(), $fieldsTextAlignRight) ? 'text-align:right;' : '') . '"><div data-barcode="EAN13" data-code="' . $itemValue . '" data-size="1" data-height="16"></div></td>';
                    } else {
                        $itemValue = $inventoryRow[$name];
                        $html .= '<td class="col-type-' . $field->getType() . '" style="border:1px solid #ddd;padding:0px 4px;' . (\in_array($field->getType(), $fieldsTextAlignRight) ? 'text-align:right;' : '') . '">';
                        if ('Name' === $field->getType()) {
                            $html .= '<strong>' . $field->getDisplayValue($itemValue, $inventoryRow, $rawText) . '</strong>';
                            foreach ($inventory->getFieldsByType('Comment') as $commentField) {
                                if ($commentField->isVisible() && ($value = $inventoryRow[$commentField->getColumnName()])) {
                                    $comment = $commentField->getDisplayValue($value, $inventoryRow, $rawText);
                                    if ($comment) {
                                        $html .= '<br>' . $comment;
                                    }
                                }
                            }
                        } elseif (\in_array($field->getType(), $fieldsWithCurrency, true)) {
                            $html .= \CurrencyField::appendCurrencySymbol($field->getDisplayValue($itemValue, $inventoryRow, $rawText), $currencySymbol);
                        } else {
                            $html .= $field->getDisplayValue($itemValue, $inventoryRow, $rawText);
                        }
                        $html .= '</td>';
                    }
                }
                $html .= '</tr>';
            }

            $html .= '</tbody><tfoot><tr>';
            foreach ($columns as $name => $field) {
                $tb = $style = '';
                if (\is_object($field) && $field->isSummary()) {
                    $style = 'border:1px solid #ddd;';
                    $sum = 0;
                    foreach ($inventoryRows as $inventoryRow) {
                        $sum += $inventoryRow[$name];
                    }
                    $tb = \CurrencyField::appendCurrencySymbol(\CurrencyField::convertToUserFormat($sum, null, true), $currencySymbol);
                }
                $html .= '<th class="col-type-' . (\is_object($field) ? $field->getType() : $name) . '" style="padding:0px 4px;text-align:right;' . $style . '">' . $tb . '</th>';
            }
            $html .= '</tr></tfoot></table>';
        }
        return $html;
    }

    /**
     * Parse variable.
     *
     * @param string $variable
     * @param int    $id
     *
     * @return string
     */
    protected function parseVariable(string $variable, int $id = 0): string
    {
        if ($id && Record::isExists($id)) {
            $recordModel = \Vtiger_Record_Model::getInstanceById($id);
            if (!$recordModel->isViewable()) {
                return '';
            }
            $instance = static::getInstanceByModel($recordModel);
        } else {
            $instance = static::getInstance();
        }
        foreach (['withoutTranslations', 'language', 'emailoptout'] as $key) {
            if (isset($this->{$key})) {
                $instance->{$key} = $this->{$key};
            }
        }
        $instance->setContent($variable)->parse();
        return $instance->getContent();
    }

    /**
     * Parse custom params.
     *
     * @param string $param
     *
     * @return array
     */
    public static function parseFieldParam(string $param): array
    {
        $part = [];
        if ($param) {
            foreach (explode('|', $param) as $type) {
                [$name, $value] = array_pad(explode('=', $type, 2), 2, '');
                $part[$name] = $value;
            }
        }
        return $part;
    }
}