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 * Register a string for which we'll need to merge in tokens.
133 * @param string $name
134 * Ex: 'subject', 'body_html'.
135 * @param string $value
136 * Ex: '<p>Hello {contact.name}</p>'.
137 * @param string $format
139 * @return TokenProcessor
141 public function addMessage($name, $value, $format) {
143 $this->visitTokens($value ?
: '', function (?
string $fullToken, ?
string $entity, ?
string $field, ?
array $modifier) use (&$tokens) {
144 $tokens[$entity][] = $field;
146 $this->messages
[$name] = [
157 * @param array|NULL $context
158 * Optionally, initialize the context for this row.
159 * Ex: ['contact_id' => 123].
162 public function addRow($context = NULL) {
163 $key = $this->next++
;
164 $this->rowContexts
[$key] = [];
165 $this->rowValues
[$key] = [
170 $row = new TokenRow($this, $key);
171 if ($context !== NULL) {
172 $row->context($context);
180 * @param array $contexts
181 * List of rows to add.
182 * Ex: [['contact_id'=>123], ['contact_id'=>456]]
184 * List of row objects
186 public function addRows($contexts) {
188 foreach ($contexts as $context) {
189 $row = $this->addRow($context);
190 $rows[$row->tokenRow
] = $row;
196 * @param array $params
198 * - entity: string, e.g. "profile".
199 * - field: string, e.g. "viewUrl".
200 * - label: string, e.g. "Default Profile URL (View Mode)".
201 * @return TokenProcessor
203 public function addToken($params) {
204 $key = $params['entity'] . '.' . $params['field'];
205 $this->tokens
[$key] = $params;
210 * @param string $name
213 * - string: Unprocessed message (eg "Hello, {display_name}.").
214 * - format: Media type (eg "text/plain").
216 public function getMessage($name) {
217 return $this->messages
[$name];
221 * Get a list of all tokens used in registered messages.
224 * The list of activated tokens, indexed by object/entity.
225 * Array(string $entityName => string[] $fieldNames)
227 * Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
228 * then $result['contact'] would be ['first_name', 'last_name'].
230 public function getMessageTokens() {
232 foreach ($this->messages
as $message) {
233 $tokens = \CRM_Utils_Array
::crmArrayMerge($tokens, $message['tokens']);
235 foreach (array_keys($tokens) as $e) {
236 $tokens[$e] = array_unique($tokens[$e]);
243 * Get a specific row (i.e. target or recipient).
245 * Ex: echo $p->getRow(2)->context['contact_id'];
246 * Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
250 * @return \Civi\Token\TokenRow
251 * The row is presented with a fluent, OOP facade.
254 public function getRow($key) {
255 return new TokenRow($this, $key);
259 * Get the list of rows (i.e. targets/recipients to generate).
262 * @return \Traversable<TokenRow>
263 * Each row is presented with a fluent, OOP facade.
265 public function getRows() {
266 return new TokenRowIterator($this, new \
ArrayIterator($this->rowContexts ?
: []));
270 * Get a list of all unique values for a given context field,
271 * whether defined at the processor or row level.
273 * @param string $field
275 * @param string|NULL $subfield
279 public function getContextValues($field, $subfield = NULL) {
281 if (isset($this->context
[$field])) {
283 if (isset($this->context
[$field]->$subfield)) {
284 $values[] = $this->context
[$field]->$subfield;
288 $values[] = $this->context
[$field];
291 foreach ($this->getRows() as $row) {
292 if (isset($row->context
[$field])) {
294 if (isset($row->context
[$field]->$subfield)) {
295 $values[] = $row->context
[$field]->$subfield;
299 $values[] = $row->context
[$field];
303 $values = array_unique($values);
308 * Get the list of available tokens.
311 * Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
313 public function getTokens() {
314 if ($this->tokens
=== NULL) {
316 $event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
317 $this->dispatcher
->dispatch('civi.token.list', $event);
319 return $this->tokens
;
323 * Get the list of available tokens, formatted for display
326 * Ex: $tokens['{token.name}'] = "Token label"
328 public function listTokens() {
329 if ($this->listTokens
=== NULL) {
330 $this->listTokens
= [];
331 foreach ($this->getTokens() as $token => $values) {
332 $this->listTokens
['{' . $token . '}'] = $values['label'];
335 return $this->listTokens
;
339 * Compute and store token values.
341 public function evaluate() {
342 $event = new TokenValueEvent($this);
343 $this->dispatcher
->dispatch('civi.token.eval', $event);
350 * @param string $name
351 * The name previously registered with addMessage().
352 * @param TokenRow|int $row
353 * The object or ID for the row previously registered with addRow().
355 * Fully rendered message, with tokens merged.
357 public function render($name, $row) {
358 if (!is_object($row)) {
359 $row = $this->getRow($row);
362 $swapLocale = empty($row->context
['locale']) ?
NULL : \CRM_Utils_AutoClean
::swapLocale($row->context
['locale']);
364 $message = $this->getMessage($name);
365 $row->fill($message['format']);
366 $useSmarty = !empty($row->context
['smarty']);
368 $tokens = $this->rowValues
[$row->tokenRow
][$message['format']];
369 $getToken = function(?
string $fullToken, ?
string $entity, ?
string $field, ?
array $modifier) use ($tokens, $useSmarty, $row) {
370 if (isset($tokens[$entity][$field])) {
371 $v = $tokens[$entity][$field];
372 $v = $this->filterTokenValue($v, $modifier, $row);
374 $v = \CRM_Utils_Token
::tokenEscapeSmarty($v);
381 $event = new TokenRenderEvent($this);
382 $event->message
= $message;
383 $event->context
= $row->context
;
385 $event->string = $this->visitTokens($message['string'] ??
'', $getToken);
386 $this->dispatcher
->dispatch('civi.token.render', $event);
387 return $event->string;
391 * Examine a token string and filter each token expression.
394 * This function is only intended for use within civicrm-core. The name/location/callback-signature may change.
395 * @param string $expression
396 * Ex: 'Hello {foo.bar} and {whiz.bang|filter:"arg"}!'
397 * @param callable $callback
398 * A function which visits (and substitutes) each token.
399 * function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier)
402 public function visitTokens(string $expression, callable
$callback): string {
403 // Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:"bang"}', '{foo.bar|whiz:"bang":"bang"}'
404 // Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}'
405 // Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s.
407 static $fullRegex = NULL;
408 if ($fullRegex === NULL) {
409 // The regex is a bit complicated, we so break it down into fragments.
410 // Consider the example '{foo.bar|whiz:"bang":"bang"}'. Each fragment matches the following:
412 $tokenRegex = '([\w]+)\.([\w:\.]+)'; /* MATCHES: 'foo.bar' */
413 $filterArgRegex = ':[\w": %\-_()\[\]\+/#@!,\.\?]*'; /* MATCHES: ':"bang":"bang"' */
414 // Key rule of filterArgRegex is to prohibit '{}'s because they may parse ambiguously. So you *might* relax it to:
415 // $filterArgRegex = ':[^{}\n]*'; /* MATCHES: ':"bang":"bang"' */
416 $filterNameRegex = "\w+"; /* MATCHES: 'whiz' */
417 $filterRegex = "\|($filterNameRegex(?:$filterArgRegex)?)"; /* MATCHES: '|whiz:"bang":"bang"' */
418 $fullRegex = ";\{$tokenRegex(?:$filterRegex)?\};";
420 return preg_replace_callback($fullRegex, function($m) use ($callback) {
424 $enqueue = function($m) use (&$filterParts) {
425 $filterParts[] = $m[1];
428 $unmatched = preg_replace_callback_array([
429 '/^(\w+)/' => $enqueue,
430 '/:"([^"]+)"/' => $enqueue,
433 throw new \
CRM_Core_Exception('Malformed token parameters (' . $m[0] . ')');
436 return $callback($m[0] ??
NULL, $m[1] ??
NULL, $m[2] ??
NULL, $filterParts);
441 * Given a token value, run it through any filters.
443 * @param mixed $value
444 * Raw token value (e.g. from `$row->tokens['foo']['bar']`).
445 * @param array|null $filter
446 * @param TokenRow $row
447 * The current target/row.
449 * @throws \CRM_Core_Exception
451 private function filterTokenValue($value, ?
array $filter, TokenRow
$row) {
452 // KISS demonstration. This should change... e.g. provide a filter-registry or reuse Smarty's registry...
454 if ($value instanceof \DateTime
&& $filter === NULL) {
455 $filter = ['crmDate'];
456 if ($value->format('His') === '000000') {
457 // if time is 'midnight' default to just date.
462 if ($value instanceof Money
&& $filter === NULL) {
463 $filter = ['crmMoney'];
466 switch ($filter[0] ??
NULL) {
471 return mb_strtoupper($value);
474 return mb_strtolower($value);
477 if ($value instanceof Money
) {
478 return \Civi
::format()->money($value->getAmount(), $value->getCurrency());
482 if ($value instanceof \DateTime
) {
484 require_once 'CRM/Core/Smarty/plugins/modifier.crmDate.php';
485 return \
smarty_modifier_crmDate($value->format('Y-m-d H:i:s'), $filter[1] ??
NULL);
489 throw new \
CRM_Core_Exception("Invalid token filter: $filter");
495 class TokenRowIterator
extends \IteratorIterator
{
498 * @var \Civi\Token\TokenProcessor
500 protected $tokenProcessor;
503 * @param TokenProcessor $tokenProcessor
504 * @param \Traversable $iterator
506 public function __construct(TokenProcessor
$tokenProcessor, Traversable
$iterator) {
507 // TODO: Change the autogenerated stub
508 parent
::__construct($iterator);
509 $this->tokenProcessor
= $tokenProcessor;
512 public function current() {
513 return new TokenRow($this->tokenProcessor
, parent
::key());