distmaker - Include `mixin/*` files
[civicrm-core.git] / Civi / Token / TokenProcessor.php
CommitLineData
8adcd073
TO
1<?php
2namespace Civi\Token;
3
f70a513f 4use Brick\Money\Money;
8adcd073
TO
5use Civi\Token\Event\TokenRegisterEvent;
6use Civi\Token\Event\TokenRenderEvent;
7use Civi\Token\Event\TokenValueEvent;
8adcd073
TO
8use Traversable;
9
cef60558
TO
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 */
8adcd073
TO
41class 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.
8996a8b6
TO
53 * - smartyTokenAlias: array, Define Smarty variables that are populated
54 * based on token-content. Ex: ['theInvoiceId' => 'contribution.invoice_id']
8adcd073
TO
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].
4b7b899b 61 * - locale: string, the name of a locale (eg 'fr_CA') to use for {ts} strings in the view.
37f37651
AS
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.
51e25a23 65 * ex: ['contactId', 'activityId'].
8adcd073
TO
66 */
67 public $context;
68
69 /**
34f3bbd9 70 * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
8adcd073
TO
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
1c4a04c9
AS
109 /**
110 * A list of available tokens formatted for display
111 * @var array
112 * Array('{' . $dottedName . '}' => 'labelString')
113 */
114 protected $listTokens = NULL;
115
8adcd073
TO
116 protected $next = 0;
117
118 /**
34f3bbd9 119 * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
8adcd073
TO
120 * @param array $context
121 */
122 public function __construct($dispatcher, $context) {
37f37651
AS
123 $context['schema'] = isset($context['schema'])
124 ? array_unique(array_merge($context['schema'], array_keys($context)))
125 : array_keys($context);
8adcd073
TO
126 $this->dispatcher = $dispatcher;
127 $this->context = $context;
128 }
129
fa75f064
TO
130 /**
131 * Add new elements to the field schema.
132 *
133 * @param string|string[] $fieldNames
134 * @return TokenProcessor
135 */
136 public function addSchema($fieldNames) {
137 $this->context['schema'] = array_unique(array_merge($this->context['schema'], (array) $fieldNames));
138 return $this;
139 }
140
8adcd073
TO
141 /**
142 * Register a string for which we'll need to merge in tokens.
143 *
144 * @param string $name
145 * Ex: 'subject', 'body_html'.
146 * @param string $value
147 * Ex: '<p>Hello {contact.name}</p>'.
148 * @param string $format
149 * Ex: 'text/html'.
4b350175 150 * @return TokenProcessor
8adcd073
TO
151 */
152 public function addMessage($name, $value, $format) {
b3041361
TO
153 $tokens = [];
154 $this->visitTokens($value ?: '', function (?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use (&$tokens) {
155 $tokens[$entity][] = $field;
156 });
c64f69d9 157 $this->messages[$name] = [
8adcd073
TO
158 'string' => $value,
159 'format' => $format,
b3041361 160 'tokens' => $tokens,
c64f69d9 161 ];
8adcd073
TO
162 return $this;
163 }
164
165 /**
166 * Add a row of data.
167 *
3faff3fc
TO
168 * @param array|NULL $context
169 * Optionally, initialize the context for this row.
170 * Ex: ['contact_id' => 123].
8adcd073
TO
171 * @return TokenRow
172 */
3faff3fc 173 public function addRow($context = NULL) {
8adcd073 174 $key = $this->next++;
c64f69d9
CW
175 $this->rowContexts[$key] = [];
176 $this->rowValues[$key] = [
177 'text/plain' => [],
178 'text/html' => [],
179 ];
8adcd073 180
3faff3fc
TO
181 $row = new TokenRow($this, $key);
182 if ($context !== NULL) {
183 $row->context($context);
184 }
185 return $row;
186 }
187
188 /**
189 * Add several rows.
190 *
191 * @param array $contexts
192 * List of rows to add.
193 * Ex: [['contact_id'=>123], ['contact_id'=>456]]
194 * @return TokenRow[]
195 * List of row objects
196 */
197 public function addRows($contexts) {
198 $rows = [];
199 foreach ($contexts as $context) {
200 $row = $this->addRow($context);
201 $rows[$row->tokenRow] = $row;
202 }
203 return $rows;
8adcd073
TO
204 }
205
206 /**
207 * @param array $params
208 * Array with keys:
209 * - entity: string, e.g. "profile".
210 * - field: string, e.g. "viewUrl".
211 * - label: string, e.g. "Default Profile URL (View Mode)".
4b350175 212 * @return TokenProcessor
8adcd073
TO
213 */
214 public function addToken($params) {
215 $key = $params['entity'] . '.' . $params['field'];
216 $this->tokens[$key] = $params;
217 return $this;
218 }
219
220 /**
221 * @param string $name
222 * @return array
223 * Keys:
224 * - string: Unprocessed message (eg "Hello, {display_name}.").
225 * - format: Media type (eg "text/plain").
226 */
227 public function getMessage($name) {
228 return $this->messages[$name];
229 }
230
231 /**
232 * Get a list of all tokens used in registered messages.
233 *
234 * @return array
cef60558
TO
235 * The list of activated tokens, indexed by object/entity.
236 * Array(string $entityName => string[] $fieldNames)
237 *
238 * Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
239 * then $result['contact'] would be ['first_name', 'last_name'].
8adcd073
TO
240 */
241 public function getMessageTokens() {
c64f69d9 242 $tokens = [];
8adcd073
TO
243 foreach ($this->messages as $message) {
244 $tokens = \CRM_Utils_Array::crmArrayMerge($tokens, $message['tokens']);
245 }
246 foreach (array_keys($tokens) as $e) {
247 $tokens[$e] = array_unique($tokens[$e]);
248 sort($tokens[$e]);
249 }
250 return $tokens;
251 }
252
cef60558
TO
253 /**
254 * Get a specific row (i.e. target or recipient).
255 *
256 * Ex: echo $p->getRow(2)->context['contact_id'];
257 * Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
258 *
259 * @param int $key
260 * The row ID
261 * @return \Civi\Token\TokenRow
262 * The row is presented with a fluent, OOP facade.
263 * @see TokenRow
264 */
8adcd073
TO
265 public function getRow($key) {
266 return new TokenRow($this, $key);
267 }
268
269 /**
cef60558
TO
270 * Get the list of rows (i.e. targets/recipients to generate).
271 *
272 * @see TokenRow
8adcd073 273 * @return \Traversable<TokenRow>
cef60558 274 * Each row is presented with a fluent, OOP facade.
8adcd073
TO
275 */
276 public function getRows() {
c06f174f 277 return new TokenRowIterator($this, new \ArrayIterator($this->rowContexts ?: []));
8adcd073
TO
278 }
279
5d798402
AS
280 /**
281 * Get a list of all unique values for a given context field,
282 * whether defined at the processor or row level.
283 *
284 * @param string $field
285 * Ex: 'contactId'.
cef60558 286 * @param string|NULL $subfield
5d798402
AS
287 * @return array
288 * Ex: [12, 34, 56].
289 */
8580ab67 290 public function getContextValues($field, $subfield = NULL) {
5d798402
AS
291 $values = [];
292 if (isset($this->context[$field])) {
8580ab67
AS
293 if ($subfield) {
294 if (isset($this->context[$field]->$subfield)) {
295 $values[] = $this->context[$field]->$subfield;
296 }
297 }
298 else {
299 $values[] = $this->context[$field];
300 }
5d798402
AS
301 }
302 foreach ($this->getRows() as $row) {
303 if (isset($row->context[$field])) {
8580ab67
AS
304 if ($subfield) {
305 if (isset($row->context[$field]->$subfield)) {
306 $values[] = $row->context[$field]->$subfield;
307 }
308 }
309 else {
310 $values[] = $row->context[$field];
311 }
5d798402
AS
312 }
313 }
314 $values = array_unique($values);
315 return $values;
316 }
317
8adcd073
TO
318 /**
319 * Get the list of available tokens.
320 *
321 * @return array
cef60558 322 * Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
8adcd073
TO
323 */
324 public function getTokens() {
325 if ($this->tokens === NULL) {
c64f69d9
CW
326 $this->tokens = [];
327 $event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
4c367668 328 $this->dispatcher->dispatch('civi.token.list', $event);
8adcd073
TO
329 }
330 return $this->tokens;
331 }
332
1c4a04c9
AS
333 /**
334 * Get the list of available tokens, formatted for display
335 *
336 * @return array
cef60558 337 * Ex: $tokens['{token.name}'] = "Token label"
1c4a04c9
AS
338 */
339 public function listTokens() {
340 if ($this->listTokens === NULL) {
c64f69d9 341 $this->listTokens = [];
1c4a04c9
AS
342 foreach ($this->getTokens() as $token => $values) {
343 $this->listTokens['{' . $token . '}'] = $values['label'];
344 }
345 }
346 return $this->listTokens;
347 }
348
8adcd073
TO
349 /**
350 * Compute and store token values.
351 */
352 public function evaluate() {
353 $event = new TokenValueEvent($this);
4c367668 354 $this->dispatcher->dispatch('civi.token.eval', $event);
8adcd073
TO
355 return $this;
356 }
357
358 /**
359 * Render a message.
360 *
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().
365 * @return string
366 * Fully rendered message, with tokens merged.
367 */
368 public function render($name, $row) {
369 if (!is_object($row)) {
370 $row = $this->getRow($row);
371 }
372
4b7b899b
TO
373 $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
374
8adcd073
TO
375 $message = $this->getMessage($name);
376 $row->fill($message['format']);
377 $useSmarty = !empty($row->context['smarty']);
378
8adcd073 379 $tokens = $this->rowValues[$row->tokenRow][$message['format']];
b3041361 380 $getToken = function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use ($tokens, $useSmarty, $row) {
c7ab366b
TO
381 if (isset($tokens[$entity][$field])) {
382 $v = $tokens[$entity][$field];
b3041361 383 $v = $this->filterTokenValue($v, $modifier, $row);
c7ab366b
TO
384 if ($useSmarty) {
385 $v = \CRM_Utils_Token::tokenEscapeSmarty($v);
386 }
387 return $v;
388 }
b3041361 389 return $fullToken;
c7ab366b 390 };
8adcd073
TO
391
392 $event = new TokenRenderEvent($this);
393 $event->message = $message;
394 $event->context = $row->context;
395 $event->row = $row;
b3041361
TO
396 $event->string = $this->visitTokens($message['string'] ?? '', $getToken);
397 $this->dispatcher->dispatch('civi.token.render', $event);
398 return $event->string;
399 }
400
8d4c4d68
TO
401 /**
402 * Examine a token string and filter each token expression.
403 *
404 * @internal
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)
411 * @return string
412 */
413 public function visitTokens(string $expression, callable $callback): string {
f85e1a4b 414 // Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:"bang"}', '{foo.bar|whiz:"bang":"bang"}'
fb4ab623
TO
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.
8d4c4d68
TO
417
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:
422
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)?\};";
430 }
431 return preg_replace_callback($fullRegex, function($m) use ($callback) {
f85e1a4b
TO
432 $filterParts = NULL;
433 if (isset($m[3])) {
434 $filterParts = [];
435 $enqueue = function($m) use (&$filterParts) {
436 $filterParts[] = $m[1];
437 return '';
438 };
439 $unmatched = preg_replace_callback_array([
440 '/^(\w+)/' => $enqueue,
441 '/:"([^"]+)"/' => $enqueue,
442 ], $m[3]);
443 if ($unmatched) {
f70a513f 444 throw new \CRM_Core_Exception('Malformed token parameters (' . $m[0] . ')');
f85e1a4b
TO
445 }
446 }
b3041361
TO
447 return $callback($m[0] ?? NULL, $m[1] ?? NULL, $m[2] ?? NULL, $filterParts);
448 }, $expression);
8adcd073
TO
449 }
450
fb4ab623
TO
451 /**
452 * Given a token value, run it through any filters.
453 *
454 * @param mixed $value
455 * Raw token value (e.g. from `$row->tokens['foo']['bar']`).
b3041361 456 * @param array|null $filter
fb4ab623
TO
457 * @param TokenRow $row
458 * The current target/row.
459 * @return string
460 * @throws \CRM_Core_Exception
461 */
b3041361 462 private function filterTokenValue($value, ?array $filter, TokenRow $row) {
fb4ab623 463 // KISS demonstration. This should change... e.g. provide a filter-registry or reuse Smarty's registry...
b3041361
TO
464
465 if ($value instanceof \DateTime && $filter === NULL) {
466 $filter = ['crmDate'];
34795e7a
EM
467 if ($value->format('His') === '000000') {
468 // if time is 'midnight' default to just date.
469 $filter[1] = 'Full';
470 }
b3041361
TO
471 }
472
f70a513f
EM
473 if ($value instanceof Money && $filter === NULL) {
474 $filter = ['crmMoney'];
475 }
476
6bf54cb8 477 switch ($filter[0] ?? NULL) {
fb4ab623
TO
478 case NULL:
479 return $value;
480
481 case 'upper':
482 return mb_strtoupper($value);
483
484 case 'lower':
485 return mb_strtolower($value);
486
f70a513f
EM
487 case 'crmMoney':
488 if ($value instanceof Money) {
489 return \Civi::format()->money($value->getAmount(), $value->getCurrency());
490 }
491
defba8ff
EM
492 case 'crmDate':
493 if ($value instanceof \DateTime) {
494 // @todo cludgey.
495 require_once 'CRM/Core/Smarty/plugins/modifier.crmDate.php';
44dd64f0 496 return \smarty_modifier_crmDate($value->format('Y-m-d H:i:s'), $filter[1] ?? NULL);
defba8ff
EM
497 }
498
fb4ab623
TO
499 default:
500 throw new \CRM_Core_Exception("Invalid token filter: $filter");
501 }
502 }
503
8adcd073
TO
504}
505
506class TokenRowIterator extends \IteratorIterator {
507
516806c9
TO
508 /**
509 * @var \Civi\Token\TokenProcessor
510 */
8adcd073
TO
511 protected $tokenProcessor;
512
513 /**
514 * @param TokenProcessor $tokenProcessor
34f3bbd9 515 * @param \Traversable $iterator
8adcd073
TO
516 */
517 public function __construct(TokenProcessor $tokenProcessor, Traversable $iterator) {
34f3bbd9
SL
518 // TODO: Change the autogenerated stub
519 parent::__construct($iterator);
8adcd073
TO
520 $this->tokenProcessor = $tokenProcessor;
521 }
522
523 public function current() {
524 return new TokenRow($this->tokenProcessor, parent::key());
525 }
526
527}