123])`).
* 2. Defining tokens/entities/data-loaders: You need to listen for TokenProcessor
* events; if any of your tokens/entities are used, then load the batch of data.
*
* Each use-case is presented with examples in the Developer Guide:
*
* @link https://docs.civicrm.org/dev/en/latest/framework/token/
*/
class TokenProcessor {
/**
* @var array
* Description of the context in which the tokens are being processed.
* Ex: Array('class'=>'CRM_Core_BAO_ActionSchedule', 'schedule' => $dao, 'mapping' => $dao).
* Ex: Array('class'=>'CRM_Mailing_BAO_MailingJob', 'mailing' => $dao).
*
* For lack of a better place, here's a list of known/intended context values:
*
* - controller: string, the class which is managing the mail-merge.
* - smarty: bool, whether to enable smarty support.
* - smartyTokenAlias: array, Define Smarty variables that are populated
* based on token-content. Ex: ['theInvoiceId' => 'contribution.invoice_id']
* - contactId: int, the main person/org discussed in the message.
* - contact: array, the main person/org discussed in the message.
* (Optional for performance tweaking; if omitted, will load
* automatically from contactId.)
* - actionSchedule: DAO, the rule which triggered the mailing
* [for CRM_Core_BAO_ActionScheduler].
* - locale: string, the name of a locale (eg 'fr_CA') to use for {ts} strings in the view.
* - schema: array, a list of fields that will be provided for each row.
* This is automatically populated with any general context
* keys, but you may need to add extra keys for token-row data.
* ex: ['contactId', 'activityId'].
*/
public $context;
/**
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var array
* Each message is an array with keys:
* - string: Unprocessed message (eg "Hello, {display_name}.").
* - format: Media type (eg "text/plain").
* - tokens: List of tokens which are actually used in this message.
*/
protected $messages;
/**
* DO NOT access field this directly. Use TokenRow. This is
* marked as public only to benefit TokenRow.
*
* @var array
* Array(int $pos => array $keyValues);
*/
public $rowContexts;
/**
* DO NOT access field this directly. Use TokenRow. This is
* marked as public only to benefit TokenRow.
*
* @var array
* Ex: $rowValues[$rowPos][$format][$entity][$field] = 'something';
* Ex: $rowValues[3]['text/plain']['contact']['display_name'] = 'something';
*/
public $rowValues;
/**
* A list of available tokens
* @var array
* Array(string $dottedName => array('entity'=>string, 'field'=>string, 'label'=>string)).
*/
protected $tokens = NULL;
/**
* A list of available tokens formatted for display
* @var array
* Array('{' . $dottedName . '}' => 'labelString')
*/
protected $listTokens = NULL;
protected $next = 0;
/**
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* @param array $context
*/
public function __construct($dispatcher, $context) {
$context['schema'] = isset($context['schema'])
? array_unique(array_merge($context['schema'], array_keys($context)))
: array_keys($context);
$this->dispatcher = $dispatcher;
$this->context = $context;
}
/**
* Register a string for which we'll need to merge in tokens.
*
* @param string $name
* Ex: 'subject', 'body_html'.
* @param string $value
* Ex: '
Hello {contact.name}
'.
* @param string $format
* Ex: 'text/html'.
* @return TokenProcessor
*/
public function addMessage($name, $value, $format) {
$tokens = [];
$this->visitTokens($value ?: '', function (?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use (&$tokens) {
$tokens[$entity][] = $field;
});
$this->messages[$name] = [
'string' => $value,
'format' => $format,
'tokens' => $tokens,
];
return $this;
}
/**
* Add a row of data.
*
* @param array|NULL $context
* Optionally, initialize the context for this row.
* Ex: ['contact_id' => 123].
* @return TokenRow
*/
public function addRow($context = NULL) {
$key = $this->next++;
$this->rowContexts[$key] = [];
$this->rowValues[$key] = [
'text/plain' => [],
'text/html' => [],
];
$row = new TokenRow($this, $key);
if ($context !== NULL) {
$row->context($context);
}
return $row;
}
/**
* Add several rows.
*
* @param array $contexts
* List of rows to add.
* Ex: [['contact_id'=>123], ['contact_id'=>456]]
* @return TokenRow[]
* List of row objects
*/
public function addRows($contexts) {
$rows = [];
foreach ($contexts as $context) {
$row = $this->addRow($context);
$rows[$row->tokenRow] = $row;
}
return $rows;
}
/**
* @param array $params
* Array with keys:
* - entity: string, e.g. "profile".
* - field: string, e.g. "viewUrl".
* - label: string, e.g. "Default Profile URL (View Mode)".
* @return TokenProcessor
*/
public function addToken($params) {
$key = $params['entity'] . '.' . $params['field'];
$this->tokens[$key] = $params;
return $this;
}
/**
* @param string $name
* @return array
* Keys:
* - string: Unprocessed message (eg "Hello, {display_name}.").
* - format: Media type (eg "text/plain").
*/
public function getMessage($name) {
return $this->messages[$name];
}
/**
* Get a list of all tokens used in registered messages.
*
* @return array
* The list of activated tokens, indexed by object/entity.
* Array(string $entityName => string[] $fieldNames)
*
* Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
* then $result['contact'] would be ['first_name', 'last_name'].
*/
public function getMessageTokens() {
$tokens = [];
foreach ($this->messages as $message) {
$tokens = \CRM_Utils_Array::crmArrayMerge($tokens, $message['tokens']);
}
foreach (array_keys($tokens) as $e) {
$tokens[$e] = array_unique($tokens[$e]);
sort($tokens[$e]);
}
return $tokens;
}
/**
* Get a specific row (i.e. target or recipient).
*
* Ex: echo $p->getRow(2)->context['contact_id'];
* Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
*
* @param int $key
* The row ID
* @return \Civi\Token\TokenRow
* The row is presented with a fluent, OOP facade.
* @see TokenRow
*/
public function getRow($key) {
return new TokenRow($this, $key);
}
/**
* Get the list of rows (i.e. targets/recipients to generate).
*
* @see TokenRow
* @return \Traversable
* Each row is presented with a fluent, OOP facade.
*/
public function getRows() {
return new TokenRowIterator($this, new \ArrayIterator($this->rowContexts ?: []));
}
/**
* Get a list of all unique values for a given context field,
* whether defined at the processor or row level.
*
* @param string $field
* Ex: 'contactId'.
* @param string|NULL $subfield
* @return array
* Ex: [12, 34, 56].
*/
public function getContextValues($field, $subfield = NULL) {
$values = [];
if (isset($this->context[$field])) {
if ($subfield) {
if (isset($this->context[$field]->$subfield)) {
$values[] = $this->context[$field]->$subfield;
}
}
else {
$values[] = $this->context[$field];
}
}
foreach ($this->getRows() as $row) {
if (isset($row->context[$field])) {
if ($subfield) {
if (isset($row->context[$field]->$subfield)) {
$values[] = $row->context[$field]->$subfield;
}
}
else {
$values[] = $row->context[$field];
}
}
}
$values = array_unique($values);
return $values;
}
/**
* Get the list of available tokens.
*
* @return array
* Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
*/
public function getTokens() {
if ($this->tokens === NULL) {
$this->tokens = [];
$event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
$this->dispatcher->dispatch('civi.token.list', $event);
}
return $this->tokens;
}
/**
* Get the list of available tokens, formatted for display
*
* @return array
* Ex: $tokens['{token.name}'] = "Token label"
*/
public function listTokens() {
if ($this->listTokens === NULL) {
$this->listTokens = [];
foreach ($this->getTokens() as $token => $values) {
$this->listTokens['{' . $token . '}'] = $values['label'];
}
}
return $this->listTokens;
}
/**
* Compute and store token values.
*/
public function evaluate() {
$event = new TokenValueEvent($this);
$this->dispatcher->dispatch('civi.token.eval', $event);
return $this;
}
/**
* Render a message.
*
* @param string $name
* The name previously registered with addMessage().
* @param TokenRow|int $row
* The object or ID for the row previously registered with addRow().
* @return string
* Fully rendered message, with tokens merged.
*/
public function render($name, $row) {
if (!is_object($row)) {
$row = $this->getRow($row);
}
$swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
$message = $this->getMessage($name);
$row->fill($message['format']);
$useSmarty = !empty($row->context['smarty']);
$tokens = $this->rowValues[$row->tokenRow][$message['format']];
$getToken = function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use ($tokens, $useSmarty, $row) {
if (isset($tokens[$entity][$field])) {
$v = $tokens[$entity][$field];
$v = $this->filterTokenValue($v, $modifier, $row);
if ($useSmarty) {
$v = \CRM_Utils_Token::tokenEscapeSmarty($v);
}
return $v;
}
return $fullToken;
};
$event = new TokenRenderEvent($this);
$event->message = $message;
$event->context = $row->context;
$event->row = $row;
$event->string = $this->visitTokens($message['string'] ?? '', $getToken);
$this->dispatcher->dispatch('civi.token.render', $event);
return $event->string;
}
/**
* Examine a token string and filter each token expression.
*
* @internal
* This function is only intended for use within civicrm-core. The name/location/callback-signature may change.
* @param string $expression
* Ex: 'Hello {foo.bar} and {whiz.bang|filter:"arg"}!'
* @param callable $callback
* A function which visits (and substitutes) each token.
* function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier)
* @return string
*/
public function visitTokens(string $expression, callable $callback): string {
// Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:"bang"}', '{foo.bar|whiz:"bang":"bang"}'
// Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}'
// Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s.
static $fullRegex = NULL;
if ($fullRegex === NULL) {
// The regex is a bit complicated, we so break it down into fragments.
// Consider the example '{foo.bar|whiz:"bang":"bang"}'. Each fragment matches the following:
$tokenRegex = '([\w]+)\.([\w:\.]+)'; /* MATCHES: 'foo.bar' */
$filterArgRegex = ':[\w": %\-_()\[\]\+/#@!,\.\?]*'; /* MATCHES: ':"bang":"bang"' */
// Key rule of filterArgRegex is to prohibit '{}'s because they may parse ambiguously. So you *might* relax it to:
// $filterArgRegex = ':[^{}\n]*'; /* MATCHES: ':"bang":"bang"' */
$filterNameRegex = "\w+"; /* MATCHES: 'whiz' */
$filterRegex = "\|($filterNameRegex(?:$filterArgRegex)?)"; /* MATCHES: '|whiz:"bang":"bang"' */
$fullRegex = ";\{$tokenRegex(?:$filterRegex)?\};";
}
return preg_replace_callback($fullRegex, function($m) use ($callback) {
$filterParts = NULL;
if (isset($m[3])) {
$filterParts = [];
$enqueue = function($m) use (&$filterParts) {
$filterParts[] = $m[1];
return '';
};
$unmatched = preg_replace_callback_array([
'/^(\w+)/' => $enqueue,
'/:"([^"]+)"/' => $enqueue,
], $m[3]);
if ($unmatched) {
throw new \CRM_Core_Exception("Malformed token parameters (" . $m[0] . ")");
}
}
return $callback($m[0] ?? NULL, $m[1] ?? NULL, $m[2] ?? NULL, $filterParts);
}, $expression);
}
/**
* Given a token value, run it through any filters.
*
* @param mixed $value
* Raw token value (e.g. from `$row->tokens['foo']['bar']`).
* @param array|null $filter
* @param TokenRow $row
* The current target/row.
* @return string
* @throws \CRM_Core_Exception
*/
private function filterTokenValue($value, ?array $filter, TokenRow $row) {
// KISS demonstration. This should change... e.g. provide a filter-registry or reuse Smarty's registry...
if ($value instanceof \DateTime && $filter === NULL) {
$filter = ['crmDate'];
}
switch ($filter[0] ?? NULL) {
case NULL:
return $value;
case 'upper':
return mb_strtoupper($value);
case 'lower':
return mb_strtolower($value);
case 'crmDate':
if ($value instanceof \DateTime) {
// @todo cludgey.
require_once 'CRM/Core/Smarty/plugins/modifier.crmDate.php';
return \smarty_modifier_crmDate($value->format('Y-m-d H:i:s'), $filter[1] ?? NULL);
}
default:
throw new \CRM_Core_Exception("Invalid token filter: $filter");
}
}
}
class TokenRowIterator extends \IteratorIterator {
/**
* @var \Civi\Token\TokenProcessor
*/
protected $tokenProcessor;
/**
* @param TokenProcessor $tokenProcessor
* @param \Traversable $iterator
*/
public function __construct(TokenProcessor $tokenProcessor, Traversable $iterator) {
// TODO: Change the autogenerated stub
parent::__construct($iterator);
$this->tokenProcessor = $tokenProcessor;
}
public function current() {
return new TokenRow($this->tokenProcessor, parent::key());
}
}