4 use Civi\Token\Event\TokenRegisterEvent
;
5 use Civi\Token\Event\TokenRenderEvent
;
6 use Civi\Token\Event\TokenValueEvent
;
7 use Symfony\Component\EventDispatcher\EventSubscriberInterface
;
10 * Class TokenCompatSubscriber
13 * This class provides a compatibility layer for using CRM_Utils_Token
14 * helpers within TokenProcessor.
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.
22 class TokenCompatSubscriber
implements EventSubscriberInterface
{
24 protected $entity = 'contact';
29 public static function getSubscribedEvents(): array {
31 'civi.token.eval' => [
32 ['setupSmartyAliases', 1000],
33 ['evaluateLegacyHookTokens', 500],
36 'civi.token.render' => 'onRender',
37 'civi.token.list' => 'registerTokens',
42 * Register the declared tokens.
44 * @param \Civi\Token\Event\TokenRegisterEvent $e
45 * The registration event. Add new tokens using register().
47 * @throws \CRM_Core_Exception
49 public function registerTokens(TokenRegisterEvent
$e): void
{
50 if (!$this->checkActive($e->getTokenProcessor())) {
53 foreach (array_merge($this->getContactTokens(), $this->getCustomFieldTokens()) as $name => $label) {
55 'entity' => $this->entity
,
60 foreach ($this->getLegacyHookTokens() as $legacyHookToken) {
62 'entity' => $legacyHookToken['category'],
63 'field' => $legacyHookToken['name'],
64 'label' => $legacyHookToken['label'],
70 * Determine whether this token-handler should be used with
71 * the given processor.
73 * To short-circuit token-processing in irrelevant contexts,
76 * @param \Civi\Token\TokenProcessor $processor
79 public function checkActive(\Civi\Token\TokenProcessor
$processor) {
80 return in_array($this->getEntityIDField(), $processor->context
['schema'], TRUE);
86 public function getEntityIDField(): string {
91 * Get functions declared using the legacy hook.
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
100 public function getLegacyHookTokens(): array {
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.
115 if (preg_match('/^\{([^\}]+)\}$/', $value, $matches)) {
116 $value = $matches[1];
118 $keyParts = explode('.', $key);
120 'category' => $keyParts[0],
121 'name' => $keyParts[1],
131 * @throws \CRM_Core_Exception
133 public function getCustomFieldTokens(): array {
135 $customFields = \CRM_Core_BAO_CustomField
::getFields(['Individual', 'Address', 'Contact']);
136 foreach ($customFields as $customField) {
137 $tokens['custom_' . $customField['id']] = $customField['label'] . " :: " . $customField['groupTitle'];
143 * Get all tokens advertised as contact tokens.
147 public function getContactTokens(): array {
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',
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',
206 'phone_ext' => 'Phone Extension',
207 'phone_type_id' => 'Phone Type ID',
208 'phone_type' => 'Phone Type',
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',
218 'checksum' => 'Checksum',
219 'contact_id' => 'Internal Contact ID',
224 * Interpret the variable `$context['smartyTokenAlias']` (e.g. `mySmartyField' => `tkn_entity.tkn_field`).
226 * We need to ensure that any tokens like `{tkn_entity.tkn_field}` are hydrated, so
227 * we pretend that they are in use.
229 * @param \Civi\Token\Event\TokenValueEvent $e
231 public function setupSmartyAliases(TokenValueEvent
$e) {
233 foreach ($e->getRows() as $row) {
234 $aliasedTokens = array_unique(array_merge($aliasedTokens,
235 array_values($row->context
['smartyTokenAlias'] ??
[])));
238 $fakeMessage = implode('', array_map(function ($f) {
239 return '{' . $f . '}';
242 $proc = $e->getTokenProcessor();
243 $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain');
247 * Load token data from legacy hooks.
249 * While our goal is for people to move towards implementing
250 * toke processors the old-style hooks can extend contact
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).
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.
261 * @param \Civi\Token\Event\TokenValueEvent $e
262 * @throws TokenException
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)) {
270 foreach ($e->getRows() as $row) {
271 if (empty($row->context
['contactId'])) {
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
280 $row->context
['contact'] = $this->getContact($row->context
['contactId'], $messageTokens['contact'] ??
[], TRUE);
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'],
287 $row->context
['controller']
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}"] ??
'');
300 * @param \Civi\Token\Event\TokenValueEvent $e
302 * @throws TokenException
303 * @throws \CRM_Core_Exception
305 public function onEvaluate(TokenValueEvent
$e) {
306 $messageTokens = $e->getTokenProcessor()->getMessageTokens()['contact'] ??
[];
307 if (empty($messageTokens)) {
310 $this->fieldMetadata
= (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name');
312 foreach ($e->getRows() as $row) {
313 if (empty($row->context
['contactId']) && empty($row->context
['contact'])) {
318 $swapLocale = empty($row->context
['locale']) ?
NULL : \CRM_Utils_AutoClean
::swapLocale($row->context
['locale']);
320 if (empty($row->context
['contact'])) {
321 $row->context
['contact'] = $this->getContact($row->context
['contactId'], $messageTokens);
324 foreach ($messageTokens as $token) {
325 if ($token === 'checksum') {
326 $cs = \CRM_Contact_BAO_Contact_Utils
::generateChecksum($row->context
['contactId'],
329 $row->context
['hash'] ??
NULL
331 $row->format('text/html')
332 ->tokens('contact', $token, "cs={$cs}");
334 elseif (!empty($row->context
['contact'][$token]) &&
335 $this->isDateField($token)
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]));
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, '');
346 elseif ($token === 'signature_html') {
347 $row->format('text/html')->tokens('contact', $token, html_entity_decode($row->context
['contact'][$token]));
350 $row->format('text/html')
351 ->tokens('contact', $token, $row->context
['contact'][$token] ??
'');
358 * Is the given field a boolean field.
360 * @param string $fieldName
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'])) {
372 if (empty($this->getFieldMetadata()[$fieldName])) {
375 return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean';
379 * Is the given field a date field.
381 * @param string $fieldName
385 public function isDateField(string $fieldName): bool {
386 if (empty($this->getFieldMetadata()[$fieldName])) {
389 return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE);
393 * Get the metadata for the available fields.
397 protected function getFieldMetadata(): array {
398 if (empty($this->fieldMetadata
)) {
400 // Tests fail without checkPermissions = FALSE
401 $this->fieldMetadata
= (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name');
403 catch (\API_Exception
$e) {
404 $this->fieldMetadata
= [];
407 return $this->fieldMetadata
;
411 * Apply the various CRM_Utils_Token helpers.
413 * @param \Civi\Token\Event\TokenRenderEvent $e
415 * @throws \CRM_Core_Exception
417 public function onRender(TokenRenderEvent
$e): void
{
418 $isHtml = ($e->message
['format'] === 'text/html');
419 $useSmarty = !empty($e->context
['smarty']);
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);
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));
439 \CRM_Core_Smarty
::singleton()->pushScope($smartyVars);
441 $e->string = \CRM_Utils_String
::parseOneOffStringThroughSmarty($e->string);
444 \CRM_Core_Smarty
::singleton()->popScope();
450 * Get the contact for the row.
452 * @param int $contactId
453 * @param array $requiredFields
454 * @param bool $getAll
457 * @throws \CRM_Core_Exception
459 protected function getContact(int $contactId, array $requiredFields, bool $getAll = FALSE): array {
460 $returnProperties = array_fill_keys($requiredFields, 1);
462 'email_greeting' => 'email_greeting_display',
463 'postal_greeting' => 'postal_greeting_display',
464 'addressee' => 'addressee_display',
466 if (!empty($returnProperties['checksum'])) {
467 $returnProperties['hash'] = 1;
470 foreach ($mappedFields as $tokenName => $realName) {
471 if (in_array($tokenName, $requiredFields, TRUE)) {
472 $returnProperties[$realName] = 1;
476 $returnProperties = array_merge($this->getAllContactReturnFields(), $returnProperties);
480 ['contact_id', '=', $contactId, 0, 0],
482 // @todo - map the parameters to apiv4 instead....
483 [$contact] = \CRM_Contact_BAO_Query
::apiQuery($params, $returnProperties ??
NULL);
485 $contact = reset($contact);
486 foreach ($mappedFields as $tokenName => $realName) {
487 $contact[$tokenName] = $contact[$realName] ??
'';
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));
501 * Get the array of the return fields from 'get all'.
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.
509 protected function getAllContactReturnFields(): array {
512 'legal_identifier' => 1,
513 'external_identifier' => 1,
515 'contact_sub_type' => 1,
518 'preferred_mail_format' => 1,
526 'communication_style_id' => 1,
529 'street_address' => 1,
530 'supplemental_address_1' => 1,
531 'supplemental_address_2' => 1,
532 'supplemental_address_3' => 1,
535 'postal_code_suffix' => 1,
536 'state_province' => 1,
545 'household_name' => 1,
546 'organization_name' => 1,
547 'deceased_date' => 1,
552 'current_employer' => 1,
559 'contact_is_deleted' => 1,
560 'preferred_communication_method' => 1,
561 'preferred_language' => 1,