5 use Civi\Token\Event\TokenRegisterEvent
;
6 use Civi\Token\Event\TokenRenderEvent
;
7 use Civi\Token\Event\TokenValueEvent
;
11 * The TokenProcessor is a template/token-engine. It is heavily influenced by
12 * traditional expectations of CiviMail, but it's adapted to an object-oriented,
17 * The CiviMail heritage gives the following expectations:
19 * - Messages are often composed of multiple parts (e.g. HTML-part, text-part, and subject-part).
20 * - Messages are often composed in batches for multiple recipients.
21 * - Tokens are denoted as `{foo.bar}`.
22 * - Data should be loaded in an optimized fashion - fetch only the needed
23 * columns, and fetch them with one query (per-table).
25 * The question of "optimized" data-loading is a key differentiator/complication.
26 * This requires some kind of communication/integration between the template-parser and data-loader.
30 * There are generally two perspectives on using TokenProcessor:
32 * 1. Composing messages: You need to specify the template contents (eg `addMessage(...)`)
33 * and the recipients' key data (eg `addRow(['contact_id' => 123])`).
34 * 2. Defining tokens/entities/data-loaders: You need to listen for TokenProcessor
35 * events; if any of your tokens/entities are used, then load the batch of data.
37 * Each use-case is presented with examples in the Developer Guide:
39 * @link https://docs.civicrm.org/dev/en/latest/framework/token/
41 class TokenProcessor
{
45 * Description of the context in which the tokens are being processed.
46 * Ex: Array('class'=>'CRM_Core_BAO_ActionSchedule', 'schedule' => $dao, 'mapping' => $dao).
47 * Ex: Array('class'=>'CRM_Mailing_BAO_MailingJob', 'mailing' => $dao).
49 * For lack of a better place, here's a list of known/intended context values:
51 * - controller: string, the class which is managing the mail-merge.
52 * - smarty: bool, whether to enable smarty support.
53 * - smartyTokenAlias: array, Define Smarty variables that are populated
54 * based on token-content. Ex: ['theInvoiceId' => 'contribution.invoice_id']
55 * - contactId: int, the main person/org discussed in the message.
56 * - contact: array, the main person/org discussed in the message.
57 * (Optional for performance tweaking; if omitted, will load
58 * automatically from contactId.)
59 * - actionSchedule: DAO, the rule which triggered the mailing
60 * [for CRM_Core_BAO_ActionScheduler].
61 * - locale: string, the name of a locale (eg 'fr_CA') to use for {ts} strings in the view.
62 * - schema: array, a list of fields that will be provided for each row.
63 * This is automatically populated with any general context
64 * keys, but you may need to add extra keys for token-row data.
65 * ex: ['contactId', 'activityId'].
70 * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
72 protected $dispatcher;
76 * Each message is an array with keys:
77 * - string: Unprocessed message (eg "Hello, {display_name}.").
78 * - format: Media type (eg "text/plain").
79 * - tokens: List of tokens which are actually used in this message.
84 * DO NOT access field this directly. Use TokenRow. This is
85 * marked as public only to benefit TokenRow.
88 * Array(int $pos => array $keyValues);
93 * DO NOT access field this directly. Use TokenRow. This is
94 * marked as public only to benefit TokenRow.
97 * Ex: $rowValues[$rowPos][$format][$entity][$field] = 'something';
98 * Ex: $rowValues[3]['text/plain']['contact']['display_name'] = 'something';
103 * A list of available tokens
105 * Array(string $dottedName => array('entity'=>string, 'field'=>string, 'label'=>string)).
107 protected $tokens = NULL;
110 * A list of available tokens formatted for display
112 * Array('{' . $dottedName . '}' => 'labelString')
114 protected $listTokens = NULL;
119 * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
120 * @param array $context
122 public function __construct($dispatcher, $context) {
123 $context['schema'] = isset($context['schema'])
124 ?
array_unique(array_merge($context['schema'], array_keys($context)))
125 : array_keys($context);
126 $this->dispatcher
= $dispatcher;
127 $this->context
= $context;
131 * Add new elements to the field schema.
133 * @param string|string[] $fieldNames
134 * @return TokenProcessor
136 public function addSchema($fieldNames) {
137 $this->context
['schema'] = array_unique(array_merge($this->context
['schema'], (array) $fieldNames));
142 * Register a string for which we'll need to merge in tokens.
144 * @param string $name
145 * Ex: 'subject', 'body_html'.
146 * @param string $value
147 * Ex: '<p>Hello {contact.name}</p>'.
148 * @param string $format
150 * @return TokenProcessor
152 public function addMessage($name, $value, $format) {
154 $this->visitTokens($value ?
: '', function (?
string $fullToken, ?
string $entity, ?
string $field, ?
array $modifier) use (&$tokens) {
155 $tokens[$entity][] = $field;
157 $this->messages
[$name] = [
168 * @param array|null $context
169 * Optionally, initialize the context for this row.
170 * Ex: ['contact_id' => 123].
173 public function addRow($context = NULL) {
174 $key = $this->next++
;
175 $this->rowContexts
[$key] = [];
176 $this->rowValues
[$key] = [
181 $row = new TokenRow($this, $key);
182 if ($context !== NULL) {
183 $row->context($context);
191 * @param array $contexts
192 * List of rows to add.
193 * Ex: [['contact_id'=>123], ['contact_id'=>456]]
195 * List of row objects
197 public function addRows($contexts) {
199 foreach ($contexts as $context) {
200 $row = $this->addRow($context);
201 $rows[$row->tokenRow
] = $row;
207 * @param array $params
209 * - entity: string, e.g. "profile".
210 * - field: string, e.g. "viewUrl".
211 * - label: string, e.g. "Default Profile URL (View Mode)".
212 * @return TokenProcessor
214 public function addToken($params) {
215 $key = $params['entity'] . '.' . $params['field'];
216 $this->tokens
[$key] = $params;
221 * @param string $name
224 * - string: Unprocessed message (eg "Hello, {display_name}.").
225 * - format: Media type (eg "text/plain").
227 public function getMessage($name) {
228 return $this->messages
[$name];
232 * Get a list of all tokens used in registered messages.
235 * The list of activated tokens, indexed by object/entity.
236 * Array(string $entityName => string[] $fieldNames)
238 * Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
239 * then $result['contact'] would be ['first_name', 'last_name'].
241 public function getMessageTokens() {
243 foreach ($this->messages
as $message) {
244 $tokens = \CRM_Utils_Array
::crmArrayMerge($tokens, $message['tokens']);
246 foreach (array_keys($tokens) as $e) {
247 $tokens[$e] = array_unique($tokens[$e]);
254 * Get a specific row (i.e. target or recipient).
256 * Ex: echo $p->getRow(2)->context['contact_id'];
257 * Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
261 * @return \Civi\Token\TokenRow
262 * The row is presented with a fluent, OOP facade.
265 public function getRow($key) {
266 return new TokenRow($this, $key);
270 * Get the list of rows (i.e. targets/recipients to generate).
273 * @return \Traversable<TokenRow>
274 * Each row is presented with a fluent, OOP facade.
276 public function getRows() {
277 return new TokenRowIterator($this, new \
ArrayIterator($this->rowContexts ?
: []));
281 * Get a list of all unique values for a given context field,
282 * whether defined at the processor or row level.
284 * @param string $field
286 * @param string|null $subfield
290 public function getContextValues($field, $subfield = NULL) {
292 if (isset($this->context
[$field])) {
294 if (isset($this->context
[$field]->$subfield)) {
295 $values[] = $this->context
[$field]->$subfield;
299 $values[] = $this->context
[$field];
302 foreach ($this->getRows() as $row) {
303 if (isset($row->context
[$field])) {
305 if (isset($row->context
[$field]->$subfield)) {
306 $values[] = $row->context
[$field]->$subfield;
310 $values[] = $row->context
[$field];
314 $values = array_unique($values);
319 * Get the list of available tokens.
322 * Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
324 public function getTokens() {
325 if ($this->tokens
=== NULL) {
327 $event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
328 $this->dispatcher
->dispatch('civi.token.list', $event);
330 return $this->tokens
;
334 * Get the list of available tokens, formatted for display
337 * Ex: $tokens['{token.name}'] = "Token label"
339 public function listTokens() {
340 if ($this->listTokens
=== NULL) {
341 $this->listTokens
= [];
342 foreach ($this->getTokens() as $token => $values) {
343 $this->listTokens
['{' . $token . '}'] = $values['label'];
346 return $this->listTokens
;
350 * Compute and store token values.
352 public function evaluate() {
353 $event = new TokenValueEvent($this);
354 $this->dispatcher
->dispatch('civi.token.eval', $event);
361 * @param string $name
362 * The name previously registered with addMessage().
363 * @param TokenRow|int $row
364 * The object or ID for the row previously registered with addRow().
366 * Fully rendered message, with tokens merged.
368 public function render($name, $row) {
369 if (!is_object($row)) {
370 $row = $this->getRow($row);
373 $swapLocale = empty($row->context
['locale']) ?
NULL : \CRM_Utils_AutoClean
::swapLocale($row->context
['locale']);
375 $message = $this->getMessage($name);
376 $row->fill($message['format']);
377 $useSmarty = !empty($row->context
['smarty']);
379 $tokens = $this->rowValues
[$row->tokenRow
][$message['format']];
380 $getToken = function(?
string $fullToken, ?
string $entity, ?
string $field, ?
array $modifier) use ($tokens, $useSmarty, $row) {
381 if (isset($tokens[$entity][$field])) {
382 $v = $tokens[$entity][$field];
383 $v = $this->filterTokenValue($v, $modifier, $row);
385 $v = \CRM_Utils_Token
::tokenEscapeSmarty($v);
392 $event = new TokenRenderEvent($this);
393 $event->message
= $message;
394 $event->context
= $row->context
;
396 $event->string = $this->visitTokens($message['string'] ??
'', $getToken);
397 $this->dispatcher
->dispatch('civi.token.render', $event);
398 return $event->string;
402 * Examine a token string and filter each token expression.
405 * This function is only intended for use within civicrm-core. The name/location/callback-signature may change.
406 * @param string $expression
407 * Ex: 'Hello {foo.bar} and {whiz.bang|filter:"arg"}!'
408 * @param callable $callback
409 * A function which visits (and substitutes) each token.
410 * function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier)
413 public function visitTokens(string $expression, callable
$callback): string {
414 // Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:"bang"}', '{foo.bar|whiz:"bang":"bang"}'
415 // Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}'
416 // Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s.
418 static $fullRegex = NULL;
419 if ($fullRegex === NULL) {
420 // The regex is a bit complicated, we so break it down into fragments.
421 // Consider the example '{foo.bar|whiz:"bang":"bang"}'. Each fragment matches the following:
423 $tokenRegex = '([\w]+)\.([\w:\.]+)'; /* MATCHES: 'foo.bar' */
424 $filterArgRegex = ':[\w": %\-_()\[\]\+/#@!,\.\?]*'; /* MATCHES: ':"bang":"bang"' */
425 // Key rule of filterArgRegex is to prohibit '{}'s because they may parse ambiguously. So you *might* relax it to:
426 // $filterArgRegex = ':[^{}\n]*'; /* MATCHES: ':"bang":"bang"' */
427 $filterNameRegex = "\w+"; /* MATCHES: 'whiz' */
428 $filterRegex = "\|($filterNameRegex(?:$filterArgRegex)?)"; /* MATCHES: '|whiz:"bang":"bang"' */
429 $fullRegex = ";\{$tokenRegex(?:$filterRegex)?\};";
431 return preg_replace_callback($fullRegex, function($m) use ($callback) {
435 $enqueue = function($m) use (&$filterParts) {
436 $filterParts[] = $m[1];
439 $unmatched = preg_replace_callback_array([
440 '/^(\w+)/' => $enqueue,
441 '/:"([^"]+)"/' => $enqueue,
444 throw new \
CRM_Core_Exception('Malformed token parameters (' . $m[0] . ')');
447 return $callback($m[0] ??
NULL, $m[1] ??
NULL, $m[2] ??
NULL, $filterParts);
452 * Given a token value, run it through any filters.
454 * @param mixed $value
455 * Raw token value (e.g. from `$row->tokens['foo']['bar']`).
456 * @param array|null $filter
457 * @param TokenRow $row
458 * The current target/row.
460 * @throws \CRM_Core_Exception
462 private function filterTokenValue($value, ?
array $filter, TokenRow
$row) {
463 // KISS demonstration. This should change... e.g. provide a filter-registry or reuse Smarty's registry...
465 if ($value instanceof \DateTime
&& $filter === NULL) {
466 $filter = ['crmDate'];
467 if ($value->format('His') === '000000') {
468 // if time is 'midnight' default to just date.
473 if ($value instanceof Money
&& $filter === NULL) {
474 $filter = ['crmMoney'];
477 switch ($filter[0] ??
NULL) {
482 return mb_strtoupper($value);
485 return mb_strtolower($value);
488 if ($value instanceof Money
) {
489 return \Civi
::format()->money($value->getAmount(), $value->getCurrency());
493 if ($value instanceof \DateTime
) {
495 require_once 'CRM/Core/Smarty/plugins/modifier.crmDate.php';
496 return \
smarty_modifier_crmDate($value->format('Y-m-d H:i:s'), $filter[1] ??
NULL);
500 throw new \
CRM_Core_Exception("Invalid token filter: $filter");
506 class TokenRowIterator
extends \IteratorIterator
{
509 * @var \Civi\Token\TokenProcessor
511 protected $tokenProcessor;
514 * @param TokenProcessor $tokenProcessor
515 * @param \Traversable $iterator
517 public function __construct(TokenProcessor
$tokenProcessor, Traversable
$iterator) {
518 // TODO: Change the autogenerated stub
519 parent
::__construct($iterator);
520 $this->tokenProcessor
= $tokenProcessor;
523 public function current() {
524 return new TokenRow($this->tokenProcessor
, parent
::key());