Merge pull request #21879 from mariav0/patch-17
[civicrm-core.git] / Civi / Token / TokenProcessor.php
1 <?php
2 namespace Civi\Token;
3
4 use Brick\Money\Money;
5 use Civi\Token\Event\TokenRegisterEvent;
6 use Civi\Token\Event\TokenRenderEvent;
7 use Civi\Token\Event\TokenValueEvent;
8 use Traversable;
9
10 /**
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,
13 * extensible design.
14 *
15 * BACKGROUND
16 *
17 * The CiviMail heritage gives the following expectations:
18 *
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).
24 *
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.
27 *
28 * USAGE
29 *
30 * There are generally two perspectives on using TokenProcessor:
31 *
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.
36 *
37 * Each use-case is presented with examples in the Developer Guide:
38 *
39 * @link https://docs.civicrm.org/dev/en/latest/framework/token/
40 */
41 class TokenProcessor {
42
43 /**
44 * @var array
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).
48 *
49 * For lack of a better place, here's a list of known/intended context values:
50 *
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'].
66 */
67 public $context;
68
69 /**
70 * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
71 */
72 protected $dispatcher;
73
74 /**
75 * @var array
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.
80 */
81 protected $messages;
82
83 /**
84 * DO NOT access field this directly. Use TokenRow. This is
85 * marked as public only to benefit TokenRow.
86 *
87 * @var array
88 * Array(int $pos => array $keyValues);
89 */
90 public $rowContexts;
91
92 /**
93 * DO NOT access field this directly. Use TokenRow. This is
94 * marked as public only to benefit TokenRow.
95 *
96 * @var array
97 * Ex: $rowValues[$rowPos][$format][$entity][$field] = 'something';
98 * Ex: $rowValues[3]['text/plain']['contact']['display_name'] = 'something';
99 */
100 public $rowValues;
101
102 /**
103 * A list of available tokens
104 * @var array
105 * Array(string $dottedName => array('entity'=>string, 'field'=>string, 'label'=>string)).
106 */
107 protected $tokens = NULL;
108
109 /**
110 * A list of available tokens formatted for display
111 * @var array
112 * Array('{' . $dottedName . '}' => 'labelString')
113 */
114 protected $listTokens = NULL;
115
116 protected $next = 0;
117
118 /**
119 * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
120 * @param array $context
121 */
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;
128 }
129
130 /**
131 * Register a string for which we'll need to merge in tokens.
132 *
133 * @param string $name
134 * Ex: 'subject', 'body_html'.
135 * @param string $value
136 * Ex: '<p>Hello {contact.name}</p>'.
137 * @param string $format
138 * Ex: 'text/html'.
139 * @return TokenProcessor
140 */
141 public function addMessage($name, $value, $format) {
142 $tokens = [];
143 $this->visitTokens($value ?: '', function (?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use (&$tokens) {
144 $tokens[$entity][] = $field;
145 });
146 $this->messages[$name] = [
147 'string' => $value,
148 'format' => $format,
149 'tokens' => $tokens,
150 ];
151 return $this;
152 }
153
154 /**
155 * Add a row of data.
156 *
157 * @param array|NULL $context
158 * Optionally, initialize the context for this row.
159 * Ex: ['contact_id' => 123].
160 * @return TokenRow
161 */
162 public function addRow($context = NULL) {
163 $key = $this->next++;
164 $this->rowContexts[$key] = [];
165 $this->rowValues[$key] = [
166 'text/plain' => [],
167 'text/html' => [],
168 ];
169
170 $row = new TokenRow($this, $key);
171 if ($context !== NULL) {
172 $row->context($context);
173 }
174 return $row;
175 }
176
177 /**
178 * Add several rows.
179 *
180 * @param array $contexts
181 * List of rows to add.
182 * Ex: [['contact_id'=>123], ['contact_id'=>456]]
183 * @return TokenRow[]
184 * List of row objects
185 */
186 public function addRows($contexts) {
187 $rows = [];
188 foreach ($contexts as $context) {
189 $row = $this->addRow($context);
190 $rows[$row->tokenRow] = $row;
191 }
192 return $rows;
193 }
194
195 /**
196 * @param array $params
197 * Array with keys:
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
202 */
203 public function addToken($params) {
204 $key = $params['entity'] . '.' . $params['field'];
205 $this->tokens[$key] = $params;
206 return $this;
207 }
208
209 /**
210 * @param string $name
211 * @return array
212 * Keys:
213 * - string: Unprocessed message (eg "Hello, {display_name}.").
214 * - format: Media type (eg "text/plain").
215 */
216 public function getMessage($name) {
217 return $this->messages[$name];
218 }
219
220 /**
221 * Get a list of all tokens used in registered messages.
222 *
223 * @return array
224 * The list of activated tokens, indexed by object/entity.
225 * Array(string $entityName => string[] $fieldNames)
226 *
227 * Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
228 * then $result['contact'] would be ['first_name', 'last_name'].
229 */
230 public function getMessageTokens() {
231 $tokens = [];
232 foreach ($this->messages as $message) {
233 $tokens = \CRM_Utils_Array::crmArrayMerge($tokens, $message['tokens']);
234 }
235 foreach (array_keys($tokens) as $e) {
236 $tokens[$e] = array_unique($tokens[$e]);
237 sort($tokens[$e]);
238 }
239 return $tokens;
240 }
241
242 /**
243 * Get a specific row (i.e. target or recipient).
244 *
245 * Ex: echo $p->getRow(2)->context['contact_id'];
246 * Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
247 *
248 * @param int $key
249 * The row ID
250 * @return \Civi\Token\TokenRow
251 * The row is presented with a fluent, OOP facade.
252 * @see TokenRow
253 */
254 public function getRow($key) {
255 return new TokenRow($this, $key);
256 }
257
258 /**
259 * Get the list of rows (i.e. targets/recipients to generate).
260 *
261 * @see TokenRow
262 * @return \Traversable<TokenRow>
263 * Each row is presented with a fluent, OOP facade.
264 */
265 public function getRows() {
266 return new TokenRowIterator($this, new \ArrayIterator($this->rowContexts ?: []));
267 }
268
269 /**
270 * Get a list of all unique values for a given context field,
271 * whether defined at the processor or row level.
272 *
273 * @param string $field
274 * Ex: 'contactId'.
275 * @param string|NULL $subfield
276 * @return array
277 * Ex: [12, 34, 56].
278 */
279 public function getContextValues($field, $subfield = NULL) {
280 $values = [];
281 if (isset($this->context[$field])) {
282 if ($subfield) {
283 if (isset($this->context[$field]->$subfield)) {
284 $values[] = $this->context[$field]->$subfield;
285 }
286 }
287 else {
288 $values[] = $this->context[$field];
289 }
290 }
291 foreach ($this->getRows() as $row) {
292 if (isset($row->context[$field])) {
293 if ($subfield) {
294 if (isset($row->context[$field]->$subfield)) {
295 $values[] = $row->context[$field]->$subfield;
296 }
297 }
298 else {
299 $values[] = $row->context[$field];
300 }
301 }
302 }
303 $values = array_unique($values);
304 return $values;
305 }
306
307 /**
308 * Get the list of available tokens.
309 *
310 * @return array
311 * Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
312 */
313 public function getTokens() {
314 if ($this->tokens === NULL) {
315 $this->tokens = [];
316 $event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
317 $this->dispatcher->dispatch('civi.token.list', $event);
318 }
319 return $this->tokens;
320 }
321
322 /**
323 * Get the list of available tokens, formatted for display
324 *
325 * @return array
326 * Ex: $tokens['{token.name}'] = "Token label"
327 */
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'];
333 }
334 }
335 return $this->listTokens;
336 }
337
338 /**
339 * Compute and store token values.
340 */
341 public function evaluate() {
342 $event = new TokenValueEvent($this);
343 $this->dispatcher->dispatch('civi.token.eval', $event);
344 return $this;
345 }
346
347 /**
348 * Render a message.
349 *
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().
354 * @return string
355 * Fully rendered message, with tokens merged.
356 */
357 public function render($name, $row) {
358 if (!is_object($row)) {
359 $row = $this->getRow($row);
360 }
361
362 $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
363
364 $message = $this->getMessage($name);
365 $row->fill($message['format']);
366 $useSmarty = !empty($row->context['smarty']);
367
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);
373 if ($useSmarty) {
374 $v = \CRM_Utils_Token::tokenEscapeSmarty($v);
375 }
376 return $v;
377 }
378 return $fullToken;
379 };
380
381 $event = new TokenRenderEvent($this);
382 $event->message = $message;
383 $event->context = $row->context;
384 $event->row = $row;
385 $event->string = $this->visitTokens($message['string'] ?? '', $getToken);
386 $this->dispatcher->dispatch('civi.token.render', $event);
387 return $event->string;
388 }
389
390 /**
391 * Examine a token string and filter each token expression.
392 *
393 * @internal
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)
400 * @return string
401 */
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.
406
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:
411
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)?\};";
419 }
420 return preg_replace_callback($fullRegex, function($m) use ($callback) {
421 $filterParts = NULL;
422 if (isset($m[3])) {
423 $filterParts = [];
424 $enqueue = function($m) use (&$filterParts) {
425 $filterParts[] = $m[1];
426 return '';
427 };
428 $unmatched = preg_replace_callback_array([
429 '/^(\w+)/' => $enqueue,
430 '/:"([^"]+)"/' => $enqueue,
431 ], $m[3]);
432 if ($unmatched) {
433 throw new \CRM_Core_Exception('Malformed token parameters (' . $m[0] . ')');
434 }
435 }
436 return $callback($m[0] ?? NULL, $m[1] ?? NULL, $m[2] ?? NULL, $filterParts);
437 }, $expression);
438 }
439
440 /**
441 * Given a token value, run it through any filters.
442 *
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.
448 * @return string
449 * @throws \CRM_Core_Exception
450 */
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...
453
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.
458 $filter[1] = 'Full';
459 }
460 }
461
462 if ($value instanceof Money && $filter === NULL) {
463 $filter = ['crmMoney'];
464 }
465
466 switch ($filter[0] ?? NULL) {
467 case NULL:
468 return $value;
469
470 case 'upper':
471 return mb_strtoupper($value);
472
473 case 'lower':
474 return mb_strtolower($value);
475
476 case 'crmMoney':
477 if ($value instanceof Money) {
478 return \Civi::format()->money($value->getAmount(), $value->getCurrency());
479 }
480
481 case 'crmDate':
482 if ($value instanceof \DateTime) {
483 // @todo cludgey.
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);
486 }
487
488 default:
489 throw new \CRM_Core_Exception("Invalid token filter: $filter");
490 }
491 }
492
493 }
494
495 class TokenRowIterator extends \IteratorIterator {
496
497 /**
498 * @var \Civi\Token\TokenProcessor
499 */
500 protected $tokenProcessor;
501
502 /**
503 * @param TokenProcessor $tokenProcessor
504 * @param \Traversable $iterator
505 */
506 public function __construct(TokenProcessor $tokenProcessor, Traversable $iterator) {
507 // TODO: Change the autogenerated stub
508 parent::__construct($iterator);
509 $this->tokenProcessor = $tokenProcessor;
510 }
511
512 public function current() {
513 return new TokenRow($this->tokenProcessor, parent::key());
514 }
515
516 }