TokenProcessor - render() and addMessage() should use same regex to find tokens
[civicrm-core.git] / Civi / Token / TokenCompatSubscriber.php
1 <?php
2 namespace Civi\Token;
3
4 use Civi\Token\Event\TokenRegisterEvent;
5 use Civi\Token\Event\TokenRenderEvent;
6 use Civi\Token\Event\TokenValueEvent;
7 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
8
9 /**
10 * Class TokenCompatSubscriber
11 * @package Civi\Token
12 *
13 * This class provides a compatibility layer for using CRM_Utils_Token
14 * helpers within TokenProcessor.
15 *
16 * THIS IS NOT A GOOD EXAMPLE TO EMULATE. The class exists to two
17 * bridge two different designs. CRM_Utils_Token has some
18 * undesirable elements (like iterative token substitution).
19 * However, if you're refactor CRM_Utils_Token or improve the
20 * bridge, then it makes sense to update this class.
21 */
22 class TokenCompatSubscriber implements EventSubscriberInterface {
23
24 protected $entity = 'contact';
25
26 /**
27 * @inheritDoc
28 */
29 public static function getSubscribedEvents(): array {
30 return [
31 'civi.token.eval' => [
32 ['setupSmartyAliases', 1000],
33 ['evaluateLegacyHookTokens', 500],
34 ['onEvaluate'],
35 ],
36 'civi.token.render' => 'onRender',
37 'civi.token.list' => 'registerTokens',
38 ];
39 }
40
41 /**
42 * Register the declared tokens.
43 *
44 * @param \Civi\Token\Event\TokenRegisterEvent $e
45 * The registration event. Add new tokens using register().
46 *
47 * @throws \CRM_Core_Exception
48 */
49 public function registerTokens(TokenRegisterEvent $e): void {
50 if (!$this->checkActive($e->getTokenProcessor())) {
51 return;
52 }
53 foreach (array_merge($this->getContactTokens(), $this->getCustomFieldTokens()) as $name => $label) {
54 $e->register([
55 'entity' => $this->entity,
56 'field' => $name,
57 'label' => $label,
58 ]);
59 }
60 foreach ($this->getLegacyHookTokens() as $legacyHookToken) {
61 $e->register([
62 'entity' => $legacyHookToken['category'],
63 'field' => $legacyHookToken['name'],
64 'label' => $legacyHookToken['label'],
65 ]);
66 }
67 }
68
69 /**
70 * Determine whether this token-handler should be used with
71 * the given processor.
72 *
73 * To short-circuit token-processing in irrelevant contexts,
74 * override this.
75 *
76 * @param \Civi\Token\TokenProcessor $processor
77 * @return bool
78 */
79 public function checkActive(\Civi\Token\TokenProcessor $processor) {
80 return in_array($this->getEntityIDField(), $processor->context['schema'], TRUE);
81 }
82
83 /**
84 * @return string
85 */
86 public function getEntityIDField(): string {
87 return 'contactId';
88 }
89
90 /**
91 * Get functions declared using the legacy hook.
92 *
93 * Note that these only extend the contact entity (
94 * ie they are based on having a contact ID which they.
95 * may or may not use, but they don't have other
96 * entity IDs.)
97 *
98 * @return array
99 */
100 public function getLegacyHookTokens(): array {
101 $tokens = [];
102 $hookTokens = [];
103 \CRM_Utils_Hook::tokens($hookTokens);
104 foreach ($hookTokens as $tokenValues) {
105 foreach ($tokenValues as $key => $value) {
106 if (is_numeric($key)) {
107 // This appears to be an attempt to compensate for
108 // inconsistencies described in https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_tokenValues/#example
109 // in effect there is a suggestion that
110 // Send an Email" and "CiviMail" send different parameters to the tokenValues hook
111 // As of now 'send an email' renders hooks through this class.
112 // CiviMail it depends on the use or otherwise of flexmailer.
113 $key = $value;
114 }
115 if (preg_match('/^\{([^\}]+)\}$/', $value, $matches)) {
116 $value = $matches[1];
117 }
118 $keyParts = explode('.', $key);
119 $tokens[$key] = [
120 'category' => $keyParts[0],
121 'name' => $keyParts[1],
122 'label' => $value,
123 ];
124 }
125 }
126 return $tokens;
127 }
128
129 /**
130 * @return array
131 * @throws \CRM_Core_Exception
132 */
133 public function getCustomFieldTokens(): array {
134 $tokens = [];
135 $customFields = \CRM_Core_BAO_CustomField::getFields(['Individual', 'Address', 'Contact']);
136 foreach ($customFields as $customField) {
137 $tokens['custom_' . $customField['id']] = $customField['label'] . " :: " . $customField['groupTitle'];
138 }
139 return $tokens;
140 }
141
142 /**
143 * Get all tokens advertised as contact tokens.
144 *
145 * @return string[]
146 */
147 public function getContactTokens(): array {
148 return [
149 'contact_type' => 'Contact Type',
150 'do_not_email' => 'Do Not Email',
151 'do_not_phone' => 'Do Not Phone',
152 'do_not_mail' => 'Do Not Mail',
153 'do_not_sms' => 'Do Not Sms',
154 'do_not_trade' => 'Do Not Trade',
155 'is_opt_out' => 'No Bulk Emails (User Opt Out)',
156 'external_identifier' => 'External Identifier',
157 'sort_name' => 'Sort Name',
158 'display_name' => 'Display Name',
159 'nick_name' => 'Nickname',
160 'image_URL' => 'Image Url',
161 'preferred_communication_method' => 'Preferred Communication Method',
162 'preferred_language' => 'Preferred Language',
163 'preferred_mail_format' => 'Preferred Mail Format',
164 'hash' => 'Contact Hash',
165 'contact_source' => 'Contact Source',
166 'first_name' => 'First Name',
167 'middle_name' => 'Middle Name',
168 'last_name' => 'Last Name',
169 'individual_prefix' => 'Individual Prefix',
170 'individual_suffix' => 'Individual Suffix',
171 'formal_title' => 'Formal Title',
172 'communication_style' => 'Communication Style',
173 'job_title' => 'Job Title',
174 'gender' => 'Gender ID',
175 'birth_date' => 'Birth Date',
176 'current_employer_id' => 'Current Employer ID',
177 'contact_is_deleted' => 'Contact is in Trash',
178 'created_date' => 'Created Date',
179 'modified_date' => 'Modified Date',
180 'addressee' => 'Addressee',
181 'email_greeting' => 'Email Greeting',
182 'postal_greeting' => 'Postal Greeting',
183 'current_employer' => 'Current Employer',
184 'location_type' => 'Location Type',
185 'address_id' => 'Address ID',
186 'street_address' => 'Street Address',
187 'street_number' => 'Street Number',
188 'street_number_suffix' => 'Street Number Suffix',
189 'street_name' => 'Street Name',
190 'street_unit' => 'Street Unit',
191 'supplemental_address_1' => 'Supplemental Address 1',
192 'supplemental_address_2' => 'Supplemental Address 2',
193 'supplemental_address_3' => 'Supplemental Address 3',
194 'city' => 'City',
195 'postal_code_suffix' => 'Postal Code Suffix',
196 'postal_code' => 'Postal Code',
197 'geo_code_1' => 'Latitude',
198 'geo_code_2' => 'Longitude',
199 'manual_geo_code' => 'Is Manually Geocoded',
200 'address_name' => 'Address Name',
201 'master_id' => 'Master Address ID',
202 'county' => 'County',
203 'state_province' => 'State',
204 'country' => 'Country',
205 'phone' => 'Phone',
206 'phone_ext' => 'Phone Extension',
207 'phone_type_id' => 'Phone Type ID',
208 'phone_type' => 'Phone Type',
209 'email' => 'Email',
210 'on_hold' => 'On Hold',
211 'signature_text' => 'Signature Text',
212 'signature_html' => 'Signature Html',
213 'im_provider' => 'IM Provider',
214 'im' => 'IM Screen Name',
215 'openid' => 'OpenID',
216 'world_region' => 'World Region',
217 'url' => 'Website',
218 'checksum' => 'Checksum',
219 'contact_id' => 'Internal Contact ID',
220 ];
221 }
222
223 /**
224 * Interpret the variable `$context['smartyTokenAlias']` (e.g. `mySmartyField' => `tkn_entity.tkn_field`).
225 *
226 * We need to ensure that any tokens like `{tkn_entity.tkn_field}` are hydrated, so
227 * we pretend that they are in use.
228 *
229 * @param \Civi\Token\Event\TokenValueEvent $e
230 */
231 public function setupSmartyAliases(TokenValueEvent $e) {
232 $aliasedTokens = [];
233 foreach ($e->getRows() as $row) {
234 $aliasedTokens = array_unique(array_merge($aliasedTokens,
235 array_values($row->context['smartyTokenAlias'] ?? [])));
236 }
237
238 $fakeMessage = implode('', array_map(function ($f) {
239 return '{' . $f . '}';
240 }, $aliasedTokens));
241
242 $proc = $e->getTokenProcessor();
243 $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain');
244 }
245
246 /**
247 * Load token data from legacy hooks.
248 *
249 * While our goal is for people to move towards implementing
250 * toke processors the old-style hooks can extend contact
251 * token data.
252 *
253 * When that is happening we need to load the full contact record
254 * to send to the hooks (not great for performance but the
255 * fix is to move away from implementing legacy style hooks).
256 *
257 * Consistent with prior behaviour we only load the contact it it
258 * is already loaded. In that scenario we also load any extra fields
259 * that might be wanted for the contact tokens.
260 *
261 * @param \Civi\Token\Event\TokenValueEvent $e
262 * @throws TokenException
263 */
264 public function evaluateLegacyHookTokens(TokenValueEvent $e): void {
265 $messageTokens = $e->getTokenProcessor()->getMessageTokens();
266 $hookTokens = array_intersect(\CRM_Utils_Token::getTokenCategories(), array_keys($messageTokens));
267 if (empty($hookTokens)) {
268 return;
269 }
270 foreach ($e->getRows() as $row) {
271 if (empty($row->context['contactId'])) {
272 continue;
273 }
274 unset($swapLocale);
275 $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
276 if (empty($row->context['contact'])) {
277 // If we don't have the contact already load it now, getting full
278 // details for hooks and anything the contact token resolution might
279 // want later.
280 $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens['contact'] ?? [], TRUE);
281 }
282 $contactArray = [$row->context['contactId'] => $row->context['contact']];
283 \CRM_Utils_Hook::tokenValues($contactArray,
284 [$row->context['contactId']],
285 empty($row->context['mailingJobId']) ? NULL : $row->context['mailingJobId'],
286 $messageTokens,
287 $row->context['controller']
288 );
289 foreach ($hookTokens as $hookToken) {
290 foreach ($messageTokens[$hookToken] as $tokenName) {
291 $row->format('text/html')->tokens($hookToken, $tokenName, $contactArray[$row->context['contactId']]["{$hookToken}.{$tokenName}"] ?? '');
292 }
293 }
294 }
295 }
296
297 /**
298 * Load token data.
299 *
300 * @param \Civi\Token\Event\TokenValueEvent $e
301 *
302 * @throws TokenException
303 * @throws \CRM_Core_Exception
304 */
305 public function onEvaluate(TokenValueEvent $e) {
306 $messageTokens = $e->getTokenProcessor()->getMessageTokens()['contact'] ?? [];
307 if (empty($messageTokens)) {
308 return;
309 }
310 $this->fieldMetadata = (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name');
311
312 foreach ($e->getRows() as $row) {
313 if (empty($row->context['contactId']) && empty($row->context['contact'])) {
314 continue;
315 }
316
317 unset($swapLocale);
318 $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
319
320 if (empty($row->context['contact'])) {
321 $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens);
322 }
323
324 foreach ($messageTokens as $token) {
325 if ($token === 'checksum') {
326 $cs = \CRM_Contact_BAO_Contact_Utils::generateChecksum($row->context['contactId'],
327 NULL,
328 NULL,
329 $row->context['hash'] ?? NULL
330 );
331 $row->format('text/html')
332 ->tokens('contact', $token, "cs={$cs}");
333 }
334 elseif (!empty($row->context['contact'][$token]) &&
335 $this->isDateField($token)
336 ) {
337 // Handle dates here, for now. Standardise with other token entities next round
338 $row->format('text/plain')->tokens('contact', $token, \CRM_Utils_Date::customFormat($row->context['contact'][$token]));
339 }
340 elseif (
341 ($row->context['contact'][$token] ?? '') == 0
342 && $this->isBooleanField($token)) {
343 // Note this will be the default behaviour once we fetch with apiv4.
344 $row->format('text/plain')->tokens('contact', $token, '');
345 }
346 elseif ($token === 'signature_html') {
347 $row->format('text/html')->tokens('contact', $token, html_entity_decode($row->context['contact'][$token]));
348 }
349 else {
350 $row->format('text/html')
351 ->tokens('contact', $token, $row->context['contact'][$token] ?? '');
352 }
353 }
354 }
355 }
356
357 /**
358 * Is the given field a boolean field.
359 *
360 * @param string $fieldName
361 *
362 * @return bool
363 */
364 public function isBooleanField(string $fieldName): bool {
365 // no metadata for these 2 non-standard fields
366 // @todo - fix to api v4 & have metadata for all fields. Migrate contact_is_deleted
367 // to {contact.is_deleted}. on hold feels like a token that exists by
368 // accident & could go.... since it's not from the main entity.
369 if (in_array($fieldName, ['contact_is_deleted', 'on_hold'])) {
370 return TRUE;
371 }
372 if (empty($this->getFieldMetadata()[$fieldName])) {
373 return FALSE;
374 }
375 return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean';
376 }
377
378 /**
379 * Is the given field a date field.
380 *
381 * @param string $fieldName
382 *
383 * @return bool
384 */
385 public function isDateField(string $fieldName): bool {
386 if (empty($this->getFieldMetadata()[$fieldName])) {
387 return FALSE;
388 }
389 return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE);
390 }
391
392 /**
393 * Get the metadata for the available fields.
394 *
395 * @return array
396 */
397 protected function getFieldMetadata(): array {
398 if (empty($this->fieldMetadata)) {
399 try {
400 // Tests fail without checkPermissions = FALSE
401 $this->fieldMetadata = (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name');
402 }
403 catch (\API_Exception $e) {
404 $this->fieldMetadata = [];
405 }
406 }
407 return $this->fieldMetadata;
408 }
409
410 /**
411 * Apply the various CRM_Utils_Token helpers.
412 *
413 * @param \Civi\Token\Event\TokenRenderEvent $e
414 *
415 * @throws \CRM_Core_Exception
416 */
417 public function onRender(TokenRenderEvent $e): void {
418 $isHtml = ($e->message['format'] === 'text/html');
419 $useSmarty = !empty($e->context['smarty']);
420
421 if (!empty($e->context['contact'])) {
422 // @todo - remove this - it simply removes the last unresolved tokens before
423 // they break smarty.
424 // historically it was only called when context['contact'] so that is
425 // retained but it only works because it's almost always true.
426 $remainingTokens = array_keys(\CRM_Utils_Token::getTokens($e->string));
427 if (!empty($remainingTokens)) {
428 $e->string = \CRM_Utils_Token::replaceHookTokens($e->string, $e->context['contact'], $remainingTokens);
429 }
430 }
431
432 if ($useSmarty) {
433 $smartyVars = [];
434 foreach ($e->context['smartyTokenAlias'] ?? [] as $smartyName => $tokenName) {
435 // Note: $e->row->tokens resolves event-based tokens (eg CRM_*_Tokens). But if the target token relies on the
436 // above bits (replaceGreetingTokens=>replaceContactTokens=>replaceHookTokens) then this lookup isn't sufficient.
437 $smartyVars[$smartyName] = \CRM_Utils_Array::pathGet($e->row->tokens, explode('.', $tokenName));
438 }
439 \CRM_Core_Smarty::singleton()->pushScope($smartyVars);
440 try {
441 $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string);
442 }
443 finally {
444 \CRM_Core_Smarty::singleton()->popScope();
445 }
446 }
447 }
448
449 /**
450 * Get the contact for the row.
451 *
452 * @param int $contactId
453 * @param array $requiredFields
454 * @param bool $getAll
455 *
456 * @return array
457 * @throws \CRM_Core_Exception
458 */
459 protected function getContact(int $contactId, array $requiredFields, bool $getAll = FALSE): array {
460 $returnProperties = array_fill_keys($requiredFields, 1);
461 $mappedFields = [
462 'email_greeting' => 'email_greeting_display',
463 'postal_greeting' => 'postal_greeting_display',
464 'addressee' => 'addressee_display',
465 ];
466 if (!empty($returnProperties['checksum'])) {
467 $returnProperties['hash'] = 1;
468 }
469
470 foreach ($mappedFields as $tokenName => $realName) {
471 if (in_array($tokenName, $requiredFields, TRUE)) {
472 $returnProperties[$realName] = 1;
473 }
474 }
475 if ($getAll) {
476 $returnProperties = array_merge($this->getAllContactReturnFields(), $returnProperties);
477 }
478
479 $params = [
480 ['contact_id', '=', $contactId, 0, 0],
481 ];
482 // @todo - map the parameters to apiv4 instead....
483 [$contact] = \CRM_Contact_BAO_Query::apiQuery($params, $returnProperties ?? NULL);
484 //CRM-4524
485 $contact = reset($contact);
486 foreach ($mappedFields as $tokenName => $realName) {
487 $contact[$tokenName] = $contact[$realName] ?? '';
488 }
489
490 //update value of custom field token
491 foreach ($requiredFields as $token) {
492 if (\CRM_Core_BAO_CustomField::getKeyID($token)) {
493 $contact[$token] = \CRM_Core_BAO_CustomField::displayValue($contact[$token], \CRM_Core_BAO_CustomField::getKeyID($token));
494 }
495 }
496
497 return $contact;
498 }
499
500 /**
501 * Get the array of the return fields from 'get all'.
502 *
503 * This is the list from the BAO_Query object but copied
504 * here to be 'frozen in time'. The goal is to map to apiv4
505 * and stop using the legacy call to load the contact.
506 *
507 * @return array
508 */
509 protected function getAllContactReturnFields(): array {
510 return [
511 'image_URL' => 1,
512 'legal_identifier' => 1,
513 'external_identifier' => 1,
514 'contact_type' => 1,
515 'contact_sub_type' => 1,
516 'sort_name' => 1,
517 'display_name' => 1,
518 'preferred_mail_format' => 1,
519 'nick_name' => 1,
520 'first_name' => 1,
521 'middle_name' => 1,
522 'last_name' => 1,
523 'prefix_id' => 1,
524 'suffix_id' => 1,
525 'formal_title' => 1,
526 'communication_style_id' => 1,
527 'birth_date' => 1,
528 'gender_id' => 1,
529 'street_address' => 1,
530 'supplemental_address_1' => 1,
531 'supplemental_address_2' => 1,
532 'supplemental_address_3' => 1,
533 'city' => 1,
534 'postal_code' => 1,
535 'postal_code_suffix' => 1,
536 'state_province' => 1,
537 'country' => 1,
538 'world_region' => 1,
539 'geo_code_1' => 1,
540 'geo_code_2' => 1,
541 'email' => 1,
542 'on_hold' => 1,
543 'phone' => 1,
544 'im' => 1,
545 'household_name' => 1,
546 'organization_name' => 1,
547 'deceased_date' => 1,
548 'is_deceased' => 1,
549 'job_title' => 1,
550 'legal_name' => 1,
551 'sic_code' => 1,
552 'current_employer' => 1,
553 'do_not_email' => 1,
554 'do_not_mail' => 1,
555 'do_not_sms' => 1,
556 'do_not_phone' => 1,
557 'do_not_trade' => 1,
558 'is_opt_out' => 1,
559 'contact_is_deleted' => 1,
560 'preferred_communication_method' => 1,
561 'preferred_language' => 1,
562 ];
563 }
564
565 }