Commit | Line | Data |
---|---|---|
8adcd073 TO |
1 | <?php |
2 | namespace Civi\Token; | |
3 | ||
f70a513f | 4 | use Brick\Money\Money; |
8adcd073 TO |
5 | use Civi\Token\Event\TokenRegisterEvent; |
6 | use Civi\Token\Event\TokenRenderEvent; | |
7 | use Civi\Token\Event\TokenValueEvent; | |
8adcd073 TO |
8 | use 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 |
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. | |
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 | ||
506 | class 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 | } |