diff --git a/Civi/Token/TokenCompatSubscriber.php b/Civi/Token/TokenCompatSubscriber.php index 3133df6eb3e4da10b1ada4f1136dd1d33f6f4139..92e20b780680f0d3c9ad2a39cca84cf1a9a4bb96 100644 --- a/Civi/Token/TokenCompatSubscriber.php +++ b/Civi/Token/TokenCompatSubscriber.php @@ -298,48 +298,135 @@ class TokenCompatSubscriber implements EventSubscriberInterface { * Load token data. * * @param \Civi\Token\Event\TokenValueEvent $e + * * @throws TokenException + * @throws \CRM_Core_Exception */ public function onEvaluate(TokenValueEvent $e) { - // For reasons unknown, replaceHookTokens used to require a pre-computed list of - // hook *categories* (aka entities aka namespaces). We cache - // this in the TokenProcessor's context but can likely remove it now. - - $e->getTokenProcessor()->context['hookTokenCategories'] = \CRM_Utils_Token::getTokenCategories(); - $messageTokens = $e->getTokenProcessor()->getMessageTokens()['contact'] ?? []; + if (empty($messageTokens)) { + return; + } + $this->fieldMetadata = (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name'); foreach ($e->getRows() as $row) { - if (empty($row->context['contactId'])) { + if (empty($row->context['contactId']) && empty($row->context['contact'])) { continue; } unset($swapLocale); $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']); - /** @var int $contactId */ - $contactId = $row->context['contactId']; if (empty($row->context['contact'])) { - $contact = $this->getContact($contactId, $messageTokens); + $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens); } - else { - $contact = $row->context['contact']; + + foreach ($messageTokens as $token) { + if ($token === 'checksum') { + $cs = \CRM_Contact_BAO_Contact_Utils::generateChecksum($row->context['contactId'], + NULL, + NULL, + $row->context['hash'] ?? NULL + ); + $row->format('text/html') + ->tokens('contact', $token, "cs={$cs}"); + } + elseif (!empty($row->context['contact'][$token]) && + $this->isDateField($token) + ) { + // Handle dates here, for now. Standardise with other token entities next round + $row->format('text/plain')->tokens('contact', $token, \CRM_Utils_Date::customFormat($row->context['contact'][$token])); + } + elseif ( + ($row->context['contact'][$token] ?? '') == 0 + && $this->isBooleanField($token)) { + // Note this will be the default behaviour once we fetch with apiv4. + $row->format('text/plain')->tokens('contact', $token, ''); + } + elseif ($token === 'signature_html') { + $row->format('text/html')->tokens('contact', $token, html_entity_decode($row->context['contact'][$token])); + } + else { + $row->format('text/html') + ->tokens('contact', $token, $row->context['contact'][$token] ?? ''); + } } - $row->context('contact', $contact); } } + /** + * Is the given field a boolean field. + * + * @param string $fieldName + * + * @return bool + */ + public function isBooleanField(string $fieldName): bool { + // no metadata for these 2 non-standard fields + // @todo - fix to api v4 & have metadata for all fields. Migrate contact_is_deleted + // to {contact.is_deleted}. on hold feels like a token that exists by + // accident & could go.... since it's not from the main entity. + if (in_array($fieldName, ['contact_is_deleted', 'on_hold'])) { + return TRUE; + } + if (empty($this->getFieldMetadata()[$fieldName])) { + return FALSE; + } + return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean'; + } + + /** + * Is the given field a date field. + * + * @param string $fieldName + * + * @return bool + */ + public function isDateField(string $fieldName): bool { + if (empty($this->getFieldMetadata()[$fieldName])) { + return FALSE; + } + return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE); + } + + /** + * Get the metadata for the available fields. + * + * @return array + */ + protected function getFieldMetadata(): array { + if (empty($this->fieldMetadata)) { + try { + // Tests fail without checkPermissions = FALSE + $this->fieldMetadata = (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name'); + } + catch (\API_Exception $e) { + $this->fieldMetadata = []; + } + } + return $this->fieldMetadata; + } + /** * Apply the various CRM_Utils_Token helpers. * * @param \Civi\Token\Event\TokenRenderEvent $e + * + * @throws \CRM_Core_Exception */ - public function onRender(TokenRenderEvent $e) { + public function onRender(TokenRenderEvent $e): void { $isHtml = ($e->message['format'] === 'text/html'); $useSmarty = !empty($e->context['smarty']); if (!empty($e->context['contact'])) { - \CRM_Utils_Token::replaceGreetingTokens($e->string, $e->context['contact'], $e->context['contact']['contact_id'] ?? $e->context['contactId'], NULL, $useSmarty); + // @todo - remove this - it simply removes the last unresolved tokens before + // they break smarty. + // historically it was only called when context['contact'] so that is + // retained but it only works because it's almost always true. + $remainingTokens = array_keys(\CRM_Utils_Token::getTokens($e->string)); + if (!empty($remainingTokens)) { + $e->string = \CRM_Utils_Token::replaceHookTokens($e->string, $e->context['contact'], $remainingTokens); + } } if ($useSmarty) { @@ -374,8 +461,12 @@ class TokenCompatSubscriber implements EventSubscriberInterface { $mappedFields = [ 'email_greeting' => 'email_greeting_display', 'postal_greeting' => 'postal_greeting_display', - 'addressee' => 'address_display', + 'addressee' => 'addressee_display', ]; + if (!empty($returnProperties['checksum'])) { + $returnProperties['hash'] = 1; + } + foreach ($mappedFields as $tokenName => $realName) { if (in_array($tokenName, $requiredFields, TRUE)) { $returnProperties[$realName] = 1; diff --git a/Civi/Token/TokenProcessor.php b/Civi/Token/TokenProcessor.php index 5411a74fecc80a27a15da702904691b985c18fb1..3bad2d891878c5bf08c94870906c4d27f8f7d2ba 100644 --- a/Civi/Token/TokenProcessor.php +++ b/Civi/Token/TokenProcessor.php @@ -383,7 +383,7 @@ class TokenProcessor { // Regex examples: '{foo.bar}', '{foo.bar|whiz}' // Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}' // Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s. - $tokRegex = '([\w]+)\.([\w:]+)'; + $tokRegex = '([\w]+)\.([\w:\.]+)'; $filterRegex = '(\w+)'; $event->string = preg_replace_callback(";\{$tokRegex(?:\|$filterRegex)?\};", $getToken, $message['string']); $this->dispatcher->dispatch('civi.token.render', $event); diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 049653d0f4f401c66291fd7ee55feea57f7800b2..ed303f27341fbef855367105c481ad6838aec3fc 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -360,6 +360,16 @@ emo '; $expected .= $this->getExpectedContactOutput($address['id'], $tokenData, $messageContent['html']); $this->assertEquals($expected, $messageContent['html']); + $textDifferences = [ + '<p>', + '</p>', + '<a href="http://civicrm.org" ', + 'target="_blank">', + '</a>', + ]; + foreach ($textDifferences as $html) { + $expected = str_replace($html, '', $expected); + } $this->assertEquals($expected, $messageContent['text']); $checksum_position = strpos($messageContent['subject'], 'cs='); $this->assertTrue($checksum_position !== FALSE); @@ -768,12 +778,12 @@ state_province:TX country:United States phone:123-456 phone_ext:77 -phone_type_id: +phone_type_id:2 phone_type:Mobile email:anthony_anderson@civicrm.org on_hold: signature_text:Yours sincerely -signature_html:<p>Yours</p> +signature_html:<p>Yours</p> im_provider:1 im:IM Screen Name openid:OpenID diff --git a/tests/phpunit/CRM/Core/FormTest.php b/tests/phpunit/CRM/Core/FormTest.php index d6f3fb2e85975a6ebd9a7d3595f77f7f48a6451b..0041d19ac39be487b4104f2b757fdba25fe3913f 100644 --- a/tests/phpunit/CRM/Core/FormTest.php +++ b/tests/phpunit/CRM/Core/FormTest.php @@ -62,7 +62,7 @@ class CRM_Core_FormTest extends CiviUnitTestCase { ]; } - public function testNewPriceField() { + public function testNewPriceField(): void { $this->createLoggedInUser(); $priceSetId = $this->callAPISuccess('PriceSet', 'create', [