[REF] Separate contact tokens from 'tokenCompatSubscriber'
authorEileen McNaughton <emcnaughton@wikimedia.org>
Sun, 26 Sep 2021 20:13:50 +0000 (09:13 +1300)
committerEileen McNaughton <emcnaughton@wikimedia.org>
Mon, 27 Sep 2021 04:35:38 +0000 (17:35 +1300)
This just moves code around - loads of testing - but it separates
the contact entity stuff from the 'compat' - presumably 'compatibility'
subscriber.

CRM/Contact/Tokens.php [new file with mode: 0644]
Civi/Core/Container.php
Civi/Token/TokenCompatSubscriber.php
tests/phpunit/Civi/Token/TokenProcessorTest.php

diff --git a/CRM/Contact/Tokens.php b/CRM/Contact/Tokens.php
new file mode 100644 (file)
index 0000000..132eff1
--- /dev/null
@@ -0,0 +1,622 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+use Civi\Token\Event\TokenRegisterEvent;
+use Civi\Token\Event\TokenRenderEvent;
+use Civi\Token\Event\TokenValueEvent;
+use Civi\Token\TokenProcessor;
+use Civi\Token\TokenRow;
+
+/**
+ * Class CRM_Contact_Tokens
+ *
+ * Generate "contact.*" tokens.
+ */
+class CRM_Contact_Tokens extends CRM_Core_EntityTokens {
+
+  /**
+   * Get the entity name for api v4 calls.
+   *
+   * @return string
+   */
+  protected function getApiEntityName(): string {
+    return 'Contact';
+  }
+
+  /**
+   * @inheritDoc
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      'civi.token.eval' => [
+        ['evaluateLegacyHookTokens', 500],
+        ['onEvaluate'],
+      ],
+      'civi.token.list' => 'registerTokens',
+    ];
+  }
+
+  /**
+   * Register the declared tokens.
+   *
+   * @param \Civi\Token\Event\TokenRegisterEvent $e
+   *   The registration event. Add new tokens using register().
+   *
+   * @throws \CRM_Core_Exception
+   */
+  public function registerTokens(TokenRegisterEvent $e): void {
+    if (!$this->checkActive($e->getTokenProcessor())) {
+      return;
+    }
+    foreach (array_merge($this->getContactTokens(), $this->getCustomFieldTokens()) as $name => $label) {
+      $e->register([
+        'entity' => $this->entity,
+        'field' => $name,
+        'label' => $label,
+      ]);
+    }
+    foreach ($this->getLegacyHookTokens() as $legacyHookToken) {
+      $e->register([
+        'entity' => $legacyHookToken['category'],
+        'field' => $legacyHookToken['name'],
+        'label' => $legacyHookToken['label'],
+      ]);
+    }
+  }
+
+  /**
+   * Determine whether this token-handler should be used with
+   * the given processor.
+   *
+   * To short-circuit token-processing in irrelevant contexts,
+   * override this.
+   *
+   * @param \Civi\Token\TokenProcessor $processor
+   * @return bool
+   */
+  public function checkActive(TokenProcessor $processor): bool {
+    return in_array($this->getEntityIDField(), $processor->context['schema'], TRUE);
+  }
+
+  /**
+   * @return string
+   */
+  public function getEntityIDField(): string {
+    return 'contactId';
+  }
+
+  /**
+   * Get functions declared using the legacy hook.
+   *
+   * Note that these only extend the contact entity (
+   * ie they are based on having a contact ID which they.
+   * may or may not use, but they don't have other
+   * entity IDs.)
+   *
+   * @return array
+   */
+  public function getLegacyHookTokens(): array {
+    $tokens = [];
+    $hookTokens = [];
+    \CRM_Utils_Hook::tokens($hookTokens);
+    foreach ($hookTokens as $tokenValues) {
+      foreach ($tokenValues as $key => $value) {
+        if (is_numeric($key)) {
+          // This appears to be an attempt to compensate for
+          // inconsistencies described in https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_tokenValues/#example
+          // in effect there is a suggestion that
+          // Send an Email" and "CiviMail" send different parameters to the tokenValues hook
+          // As of now 'send an email' renders hooks through this class.
+          // CiviMail it depends on the use or otherwise of flexmailer.
+          $key = $value;
+        }
+        if (preg_match('/^\{([^\}]+)\}$/', $value, $matches)) {
+          $value = $matches[1];
+        }
+        $keyParts = explode('.', $key);
+        $tokens[$key] = [
+          'category' => $keyParts[0],
+          'name' => $keyParts[1],
+          'label' => $value,
+        ];
+      }
+    }
+    return $tokens;
+  }
+
+  /**
+   * @return array
+   * @throws \CRM_Core_Exception
+   */
+  public function getCustomFieldTokens(): array {
+    $tokens = [];
+    $customFields = \CRM_Core_BAO_CustomField::getFields(['Individual', 'Address', 'Contact']);
+    foreach ($customFields as $customField) {
+      $tokens['custom_' . $customField['id']] = $customField['label'] . " :: " . $customField['groupTitle'];
+    }
+    return $tokens;
+  }
+
+  /**
+   * Get all tokens advertised as contact tokens.
+   *
+   * @return string[]
+   */
+  public function getContactTokens(): array {
+    return [
+      'contact_type' => 'Contact Type',
+      'do_not_email' => 'Do Not Email',
+      'do_not_phone' => 'Do Not Phone',
+      'do_not_mail' => 'Do Not Mail',
+      'do_not_sms' => 'Do Not Sms',
+      'do_not_trade' => 'Do Not Trade',
+      'is_opt_out' => 'No Bulk Emails (User Opt Out)',
+      'external_identifier' => 'External Identifier',
+      'sort_name' => 'Sort Name',
+      'display_name' => 'Display Name',
+      'nick_name' => 'Nickname',
+      'image_URL' => 'Image Url',
+      'preferred_communication_method:label' => 'Preferred Communication Method',
+      'preferred_language:label' => 'Preferred Language',
+      'preferred_mail_format:label' => 'Preferred Mail Format',
+      'hash' => 'Contact Hash',
+      'source' => 'Contact Source',
+      'first_name' => 'First Name',
+      'middle_name' => 'Middle Name',
+      'last_name' => 'Last Name',
+      'prefix_id:label' => 'Individual Prefix',
+      'suffix_id:label' => 'Individual Suffix',
+      'formal_title' => 'Formal Title',
+      'communication_style_id:label' => 'Communication Style',
+      'job_title' => 'Job Title',
+      'gender_id:label' => 'Gender ID',
+      'birth_date' => 'Birth Date',
+      'current_employer_id' => 'Current Employer ID',
+      'is_deleted:label' => 'Contact is in Trash',
+      'created_date' => 'Created Date',
+      'modified_date' => 'Modified Date',
+      'addressee_display' => 'Addressee',
+      'email_greeting_display' => 'Email Greeting',
+      'postal_greeting_display' => 'Postal Greeting',
+      'current_employer' => 'Current Employer',
+      'location_type' => 'Location Type',
+      'address_id' => 'Address ID',
+      'street_address' => 'Street Address',
+      'street_number' => 'Street Number',
+      'street_number_suffix' => 'Street Number Suffix',
+      'street_name' => 'Street Name',
+      'street_unit' => 'Street Unit',
+      'supplemental_address_1' => 'Supplemental Address 1',
+      'supplemental_address_2' => 'Supplemental Address 2',
+      'supplemental_address_3' => 'Supplemental Address 3',
+      'city' => 'City',
+      'postal_code_suffix' => 'Postal Code Suffix',
+      'postal_code' => 'Postal Code',
+      'geo_code_1' => 'Latitude',
+      'geo_code_2' => 'Longitude',
+      'manual_geo_code' => 'Is Manually Geocoded',
+      'address_name' => 'Address Name',
+      'master_id' => 'Master Address ID',
+      'county' => 'County',
+      'state_province' => 'State',
+      'country' => 'Country',
+      'phone' => 'Phone',
+      'phone_ext' => 'Phone Extension',
+      'phone_type_id' => 'Phone Type ID',
+      'phone_type' => 'Phone Type',
+      'email' => 'Email',
+      'on_hold' => 'On Hold',
+      'signature_text' => 'Signature Text',
+      'signature_html' => 'Signature Html',
+      'im_provider' => 'IM Provider',
+      'im' => 'IM Screen Name',
+      'openid' => 'OpenID',
+      'world_region' => 'World Region',
+      'url' => 'Website',
+      'checksum' => 'Checksum',
+      'id' => 'Internal Contact ID',
+    ];
+  }
+
+  /**
+   * Interpret the variable `$context['smartyTokenAlias']` (e.g. `mySmartyField' => `tkn_entity.tkn_field`).
+   *
+   * We need to ensure that any tokens like `{tkn_entity.tkn_field}` are hydrated, so
+   * we pretend that they are in use.
+   *
+   * @param \Civi\Token\Event\TokenValueEvent $e
+   */
+  public function setupSmartyAliases(TokenValueEvent $e) {
+    $aliasedTokens = [];
+    foreach ($e->getRows() as $row) {
+      $aliasedTokens = array_unique(array_merge($aliasedTokens,
+        array_values($row->context['smartyTokenAlias'] ?? [])));
+    }
+
+    $fakeMessage = implode('', array_map(function ($f) {
+      return '{' . $f . '}';
+    }, $aliasedTokens));
+
+    $proc = $e->getTokenProcessor();
+    $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain');
+  }
+
+  /**
+   * Load token data from legacy hooks.
+   *
+   * While our goal is for people to move towards implementing
+   * toke processors the old-style hooks can extend contact
+   * token data.
+   *
+   * When that is happening we need to load the full contact record
+   * to send to the hooks (not great for performance but the
+   * fix is to move away from implementing legacy style hooks).
+   *
+   * Consistent with prior behaviour we only load the contact it it
+   * is already loaded. In that scenario we also load any extra fields
+   * that might be wanted for the contact tokens.
+   *
+   * @param \Civi\Token\Event\TokenValueEvent $e
+   * @throws TokenException
+   */
+  public function evaluateLegacyHookTokens(TokenValueEvent $e): void {
+    $messageTokens = $e->getTokenProcessor()->getMessageTokens();
+    $hookTokens = array_intersect(\CRM_Utils_Token::getTokenCategories(), array_keys($messageTokens));
+    if (empty($hookTokens)) {
+      return;
+    }
+    foreach ($e->getRows() as $row) {
+      if (empty($row->context['contactId'])) {
+        continue;
+      }
+      unset($swapLocale);
+      $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
+      if (empty($row->context['contact'])) {
+        // If we don't have the contact already load it now, getting full
+        // details for hooks and anything the contact token resolution might
+        // want later.
+        $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens['contact'] ?? [], TRUE);
+      }
+      $contactArray = [$row->context['contactId'] => $row->context['contact']];
+      \CRM_Utils_Hook::tokenValues($contactArray,
+        [$row->context['contactId']],
+        empty($row->context['mailingJobId']) ? NULL : $row->context['mailingJobId'],
+        $messageTokens,
+        $row->context['controller']
+      );
+      foreach ($hookTokens as $hookToken) {
+        foreach ($messageTokens[$hookToken] as $tokenName) {
+          $row->format('text/html')->tokens($hookToken, $tokenName, $contactArray[$row->context['contactId']]["{$hookToken}.{$tokenName}"] ?? '');
+        }
+      }
+    }
+  }
+
+  /**
+   * Load token data.
+   *
+   * @param \Civi\Token\Event\TokenValueEvent $e
+   *
+   * @throws TokenException
+   * @throws \CRM_Core_Exception
+   */
+  public function onEvaluate(TokenValueEvent $e) {
+    $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']) && empty($row->context['contact'])) {
+        continue;
+      }
+
+      unset($swapLocale);
+      $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
+
+      if (empty($row->context['contact'])) {
+        $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens);
+      }
+
+      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, $this->getFieldValue($row, $token));
+        }
+      }
+    }
+  }
+
+  /**
+   * Get the field value.
+   *
+   * @param \Civi\Token\TokenRow $row
+   * @param string $field
+   * @return string|int
+   */
+  protected function getFieldValue(TokenRow $row, string $field) {
+    $entityName = 'contact';
+    if (isset($this->getDeprecatedTokens()[$field])) {
+      // Check the non-deprecated location first, fall back to deprecated
+      // this is important for the greetings because - they are weird in the query object.
+      $possibilities = [$this->getDeprecatedTokens()[$field], $field];
+    }
+    else {
+      $possibilities = [$field];
+      if (in_array($field, $this->getDeprecatedTokens(), TRUE)) {
+        $possibilities[] = array_search($field, $this->getDeprecatedTokens(), TRUE);
+      }
+    }
+
+    foreach ($possibilities as $possibility) {
+      if (isset($row->context[$entityName][$possibility])) {
+        return $row->context[$entityName][$possibility];
+      }
+    }
+    return '';
+  }
+
+  /**
+   * 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
+   */
+  public function onRender(TokenRenderEvent $e): void {
+    $useSmarty = !empty($e->context['smarty']);
+    $e->string = $e->getTokenProcessor()->visitTokens($e->string, function() {
+      // For historical consistency, we filter out unrecognized tokens.
+      return '';
+    });
+
+    if ($useSmarty) {
+      $smartyVars = [];
+      foreach ($e->context['smartyTokenAlias'] ?? [] as $smartyName => $tokenName) {
+        $smartyVars[$smartyName] = \CRM_Utils_Array::pathGet($e->row->tokens, explode('.', $tokenName));
+      }
+      \CRM_Core_Smarty::singleton()->pushScope($smartyVars);
+      try {
+        $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string);
+      }
+      finally {
+        \CRM_Core_Smarty::singleton()->popScope();
+      }
+    }
+  }
+
+  /**
+   * Get the contact for the row.
+   *
+   * @param int $contactId
+   * @param array $requiredFields
+   * @param bool $getAll
+   *
+   * @return array
+   * @throws \CRM_Core_Exception
+   */
+  protected function getContact(int $contactId, array $requiredFields, bool $getAll = FALSE): array {
+    $returnProperties = array_fill_keys($requiredFields, 1);
+    $mappedFields = array_flip($this->getDeprecatedTokens());
+
+    if (!empty($returnProperties['checksum'])) {
+      $returnProperties['hash'] = 1;
+    }
+
+    foreach ($mappedFields as $tokenName => $api3Name) {
+      if (in_array($tokenName, $requiredFields, TRUE)) {
+        $returnProperties[$api3Name] = 1;
+      }
+    }
+    if ($getAll) {
+      $returnProperties = array_merge($this->getAllContactReturnFields(), $returnProperties);
+    }
+
+    $params = [
+      ['contact_id', '=', $contactId, 0, 0],
+    ];
+    // @todo - map the parameters to apiv4 instead....
+    [$contact] = \CRM_Contact_BAO_Query::apiQuery($params, $returnProperties ?? NULL);
+    //CRM-4524
+    $contact = reset($contact);
+    foreach ($mappedFields as $tokenName => $apiv3Name) {
+      // it would be set already with the right value for a greeting token
+      // the query object returns the db value for email_greeting_display
+      // and a numeric value for email_greeting if you put email_greeting
+      // in the return properties.
+      if (!isset($contact[$tokenName])) {
+        $contact[$tokenName] = $contact[$apiv3Name] ?? '';
+      }
+    }
+
+    //update value of custom field token
+    foreach ($requiredFields as $token) {
+      if (\CRM_Core_BAO_CustomField::getKeyID($token)) {
+        $contact[$token] = \CRM_Core_BAO_CustomField::displayValue($contact[$token], \CRM_Core_BAO_CustomField::getKeyID($token));
+      }
+    }
+
+    return $contact;
+  }
+
+  /**
+   * Get the array of the return fields from 'get all'.
+   *
+   * This is the list from the BAO_Query object but copied
+   * here to be 'frozen in time'. The goal is to map to apiv4
+   * and stop using the legacy call to load the contact.
+   *
+   * @return array
+   */
+  protected function getAllContactReturnFields(): array {
+    return [
+      'image_URL' => 1,
+      'legal_identifier' => 1,
+      'external_identifier' => 1,
+      'contact_type' => 1,
+      'contact_sub_type' => 1,
+      'sort_name' => 1,
+      'display_name' => 1,
+      'preferred_mail_format' => 1,
+      'nick_name' => 1,
+      'first_name' => 1,
+      'middle_name' => 1,
+      'last_name' => 1,
+      'prefix_id' => 1,
+      'suffix_id' => 1,
+      'formal_title' => 1,
+      'communication_style_id' => 1,
+      'birth_date' => 1,
+      'gender_id' => 1,
+      'street_address' => 1,
+      'supplemental_address_1' => 1,
+      'supplemental_address_2' => 1,
+      'supplemental_address_3' => 1,
+      'city' => 1,
+      'postal_code' => 1,
+      'postal_code_suffix' => 1,
+      'state_province' => 1,
+      'country' => 1,
+      'world_region' => 1,
+      'geo_code_1' => 1,
+      'geo_code_2' => 1,
+      'email' => 1,
+      'on_hold' => 1,
+      'phone' => 1,
+      'im' => 1,
+      'household_name' => 1,
+      'organization_name' => 1,
+      'deceased_date' => 1,
+      'is_deceased' => 1,
+      'job_title' => 1,
+      'legal_name' => 1,
+      'sic_code' => 1,
+      'current_employer' => 1,
+      'do_not_email' => 1,
+      'do_not_mail' => 1,
+      'do_not_sms' => 1,
+      'do_not_phone' => 1,
+      'do_not_trade' => 1,
+      'is_opt_out' => 1,
+      'contact_is_deleted' => 1,
+      'preferred_communication_method' => 1,
+      'preferred_language' => 1,
+    ];
+  }
+
+  /**
+   * These tokens still work but we don't advertise them.
+   *
+   * We can remove from the following places
+   * - scheduled reminders
+   * - add to 'blocked' on pdf letter & email
+   *
+   * & then at some point start issuing warnings for them
+   * but contact tokens are pretty central so it might be
+   * a bit drawn out.
+   *
+   * @return string[]
+   *   Keys are deprecated tokens and values are their replacements.
+   */
+  protected function getDeprecatedTokens(): array {
+    return [
+      'individual_prefix' => 'prefix_id:label',
+      'individual_suffix' => 'suffix_id:label',
+      'gender' => 'gender_id:label',
+      'communication_style' => 'communication_style_id:label',
+      'preferred_communication_method' => 'preferred_communication_method:label',
+      'email_greeting' => 'email_greeting_display',
+      'postal_greeting' => 'postal_greeting_display',
+      'addressee' => 'addressee_display',
+      'contact_id' => 'id',
+      'contact_source' => 'source',
+      'contact_is_deleted' => 'is_deleted:label',
+    ];
+  }
+
+}
index 69f05d1bec1718f1ff4bc40633ce1bc5bba34f2b..268938f68f7b717acd9ed5401d8cb5e2a4f5603d 100644 (file)
@@ -327,7 +327,7 @@ class Container {
       []
     ))->addTag('kernel.event_subscriber')->setPublic(TRUE);
 
-    foreach (['Activity', 'Contribute', 'Event', 'Mailing', 'Member', 'Case'] as $comp) {
+    foreach (['Activity', 'Contact', 'Contribute', 'Event', 'Mailing', 'Member', 'Case'] as $comp) {
       $container->setDefinition('crm_' . strtolower($comp) . '_tokens', new Definition(
         "CRM_{$comp}_Tokens",
         []
index 20ad8f03a3495e426e91418745f7bf0b91c3d637..e14539489773781f919c39cfae23f69c3e7bbd54 100644 (file)
@@ -1,7 +1,6 @@
 <?php
 namespace Civi\Token;
 
-use Civi\Token\Event\TokenRegisterEvent;
 use Civi\Token\Event\TokenRenderEvent;
 use Civi\Token\Event\TokenValueEvent;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -10,19 +9,10 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  * Class TokenCompatSubscriber
  * @package Civi\Token
  *
- * This class provides a compatibility layer for using CRM_Utils_Token
- * helpers within TokenProcessor.
- *
- * THIS IS NOT A GOOD EXAMPLE TO EMULATE. The class exists to two
- * bridge two different designs. CRM_Utils_Token has some
- * undesirable elements (like iterative token substitution).
- * However, if you're refactor CRM_Utils_Token or improve the
- * bridge, then it makes sense to update this class.
+ * This class handles the smarty processing of tokens.
  */
 class TokenCompatSubscriber implements EventSubscriberInterface {
 
-  protected $entity = 'contact';
-
   /**
    * @inheritDoc
    */
@@ -30,193 +20,8 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
     return [
       'civi.token.eval' => [
         ['setupSmartyAliases', 1000],
-        ['evaluateLegacyHookTokens', 500],
-        ['onEvaluate'],
       ],
       'civi.token.render' => 'onRender',
-      'civi.token.list' => 'registerTokens',
-    ];
-  }
-
-  /**
-   * Register the declared tokens.
-   *
-   * @param \Civi\Token\Event\TokenRegisterEvent $e
-   *   The registration event. Add new tokens using register().
-   *
-   * @throws \CRM_Core_Exception
-   */
-  public function registerTokens(TokenRegisterEvent $e): void {
-    if (!$this->checkActive($e->getTokenProcessor())) {
-      return;
-    }
-    foreach (array_merge($this->getContactTokens(), $this->getCustomFieldTokens()) as $name => $label) {
-      $e->register([
-        'entity' => $this->entity,
-        'field' => $name,
-        'label' => $label,
-      ]);
-    }
-    foreach ($this->getLegacyHookTokens() as $legacyHookToken) {
-      $e->register([
-        'entity' => $legacyHookToken['category'],
-        'field' => $legacyHookToken['name'],
-        'label' => $legacyHookToken['label'],
-      ]);
-    }
-  }
-
-  /**
-   * Determine whether this token-handler should be used with
-   * the given processor.
-   *
-   * To short-circuit token-processing in irrelevant contexts,
-   * override this.
-   *
-   * @param \Civi\Token\TokenProcessor $processor
-   * @return bool
-   */
-  public function checkActive(\Civi\Token\TokenProcessor $processor) {
-    return in_array($this->getEntityIDField(), $processor->context['schema'], TRUE);
-  }
-
-  /**
-   * @return string
-   */
-  public function getEntityIDField(): string {
-    return 'contactId';
-  }
-
-  /**
-   * Get functions declared using the legacy hook.
-   *
-   * Note that these only extend the contact entity (
-   * ie they are based on having a contact ID which they.
-   * may or may not use, but they don't have other
-   * entity IDs.)
-   *
-   * @return array
-   */
-  public function getLegacyHookTokens(): array {
-    $tokens = [];
-    $hookTokens = [];
-    \CRM_Utils_Hook::tokens($hookTokens);
-    foreach ($hookTokens as $tokenValues) {
-      foreach ($tokenValues as $key => $value) {
-        if (is_numeric($key)) {
-          // This appears to be an attempt to compensate for
-          // inconsistencies described in https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_tokenValues/#example
-          // in effect there is a suggestion that
-          // Send an Email" and "CiviMail" send different parameters to the tokenValues hook
-          // As of now 'send an email' renders hooks through this class.
-          // CiviMail it depends on the use or otherwise of flexmailer.
-          $key = $value;
-        }
-        if (preg_match('/^\{([^\}]+)\}$/', $value, $matches)) {
-          $value = $matches[1];
-        }
-        $keyParts = explode('.', $key);
-        $tokens[$key] = [
-          'category' => $keyParts[0],
-          'name' => $keyParts[1],
-          'label' => $value,
-        ];
-      }
-    }
-    return $tokens;
-  }
-
-  /**
-   * @return array
-   * @throws \CRM_Core_Exception
-   */
-  public function getCustomFieldTokens(): array {
-    $tokens = [];
-    $customFields = \CRM_Core_BAO_CustomField::getFields(['Individual', 'Address', 'Contact']);
-    foreach ($customFields as $customField) {
-      $tokens['custom_' . $customField['id']] = $customField['label'] . " :: " . $customField['groupTitle'];
-    }
-    return $tokens;
-  }
-
-  /**
-   * Get all tokens advertised as contact tokens.
-   *
-   * @return string[]
-   */
-  public function getContactTokens(): array {
-    return [
-      'contact_type' => 'Contact Type',
-      'do_not_email' => 'Do Not Email',
-      'do_not_phone' => 'Do Not Phone',
-      'do_not_mail' => 'Do Not Mail',
-      'do_not_sms' => 'Do Not Sms',
-      'do_not_trade' => 'Do Not Trade',
-      'is_opt_out' => 'No Bulk Emails (User Opt Out)',
-      'external_identifier' => 'External Identifier',
-      'sort_name' => 'Sort Name',
-      'display_name' => 'Display Name',
-      'nick_name' => 'Nickname',
-      'image_URL' => 'Image Url',
-      'preferred_communication_method:label' => 'Preferred Communication Method',
-      'preferred_language:label' => 'Preferred Language',
-      'preferred_mail_format:label' => 'Preferred Mail Format',
-      'hash' => 'Contact Hash',
-      'source' => 'Contact Source',
-      'first_name' => 'First Name',
-      'middle_name' => 'Middle Name',
-      'last_name' => 'Last Name',
-      'prefix_id:label' => 'Individual Prefix',
-      'suffix_id:label' => 'Individual Suffix',
-      'formal_title' => 'Formal Title',
-      'communication_style_id:label' => 'Communication Style',
-      'job_title' => 'Job Title',
-      'gender_id:label' => 'Gender ID',
-      'birth_date' => 'Birth Date',
-      'current_employer_id' => 'Current Employer ID',
-      'is_deleted:label' => 'Contact is in Trash',
-      'created_date' => 'Created Date',
-      'modified_date' => 'Modified Date',
-      'addressee_display' => 'Addressee',
-      'email_greeting_display' => 'Email Greeting',
-      'postal_greeting_display' => 'Postal Greeting',
-      'current_employer' => 'Current Employer',
-      'location_type' => 'Location Type',
-      'address_id' => 'Address ID',
-      'street_address' => 'Street Address',
-      'street_number' => 'Street Number',
-      'street_number_suffix' => 'Street Number Suffix',
-      'street_name' => 'Street Name',
-      'street_unit' => 'Street Unit',
-      'supplemental_address_1' => 'Supplemental Address 1',
-      'supplemental_address_2' => 'Supplemental Address 2',
-      'supplemental_address_3' => 'Supplemental Address 3',
-      'city' => 'City',
-      'postal_code_suffix' => 'Postal Code Suffix',
-      'postal_code' => 'Postal Code',
-      'geo_code_1' => 'Latitude',
-      'geo_code_2' => 'Longitude',
-      'manual_geo_code' => 'Is Manually Geocoded',
-      'address_name' => 'Address Name',
-      'master_id' => 'Master Address ID',
-      'county' => 'County',
-      'state_province' => 'State',
-      'country' => 'Country',
-      'phone' => 'Phone',
-      'phone_ext' => 'Phone Extension',
-      'phone_type_id' => 'Phone Type ID',
-      'phone_type' => 'Phone Type',
-      'email' => 'Email',
-      'on_hold' => 'On Hold',
-      'signature_text' => 'Signature Text',
-      'signature_html' => 'Signature Html',
-      'im_provider' => 'IM Provider',
-      'im' => 'IM Screen Name',
-      'openid' => 'OpenID',
-      'world_region' => 'World Region',
-      'url' => 'Website',
-      'checksum' => 'Checksum',
-      'id' => 'Internal Contact ID',
     ];
   }
 
@@ -243,199 +48,6 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
     $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain');
   }
 
-  /**
-   * Load token data from legacy hooks.
-   *
-   * While our goal is for people to move towards implementing
-   * toke processors the old-style hooks can extend contact
-   * token data.
-   *
-   * When that is happening we need to load the full contact record
-   * to send to the hooks (not great for performance but the
-   * fix is to move away from implementing legacy style hooks).
-   *
-   * Consistent with prior behaviour we only load the contact it it
-   * is already loaded. In that scenario we also load any extra fields
-   * that might be wanted for the contact tokens.
-   *
-   * @param \Civi\Token\Event\TokenValueEvent $e
-   * @throws TokenException
-   */
-  public function evaluateLegacyHookTokens(TokenValueEvent $e): void {
-    $messageTokens = $e->getTokenProcessor()->getMessageTokens();
-    $hookTokens = array_intersect(\CRM_Utils_Token::getTokenCategories(), array_keys($messageTokens));
-    if (empty($hookTokens)) {
-      return;
-    }
-    foreach ($e->getRows() as $row) {
-      if (empty($row->context['contactId'])) {
-        continue;
-      }
-      unset($swapLocale);
-      $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
-      if (empty($row->context['contact'])) {
-        // If we don't have the contact already load it now, getting full
-        // details for hooks and anything the contact token resolution might
-        // want later.
-        $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens['contact'] ?? [], TRUE);
-      }
-      $contactArray = [$row->context['contactId'] => $row->context['contact']];
-      \CRM_Utils_Hook::tokenValues($contactArray,
-        [$row->context['contactId']],
-        empty($row->context['mailingJobId']) ? NULL : $row->context['mailingJobId'],
-        $messageTokens,
-        $row->context['controller']
-      );
-      foreach ($hookTokens as $hookToken) {
-        foreach ($messageTokens[$hookToken] as $tokenName) {
-          $row->format('text/html')->tokens($hookToken, $tokenName, $contactArray[$row->context['contactId']]["{$hookToken}.{$tokenName}"] ?? '');
-        }
-      }
-    }
-  }
-
-  /**
-   * Load token data.
-   *
-   * @param \Civi\Token\Event\TokenValueEvent $e
-   *
-   * @throws TokenException
-   * @throws \CRM_Core_Exception
-   */
-  public function onEvaluate(TokenValueEvent $e) {
-    $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']) && empty($row->context['contact'])) {
-        continue;
-      }
-
-      unset($swapLocale);
-      $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
-
-      if (empty($row->context['contact'])) {
-        $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens);
-      }
-
-      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, $this->getFieldValue($row, $token));
-        }
-      }
-    }
-  }
-
-  /**
-   * Get the field value.
-   *
-   * @param \Civi\Token\TokenRow $row
-   * @param string $field
-   * @return string|int
-   */
-  protected function getFieldValue(TokenRow $row, string $field) {
-    $entityName = 'contact';
-    if (isset($this->getDeprecatedTokens()[$field])) {
-      // Check the non-deprecated location first, fall back to deprecated
-      // this is important for the greetings because - they are weird in the query object.
-      $possibilities = [$this->getDeprecatedTokens()[$field], $field];
-    }
-    else {
-      $possibilities = [$field];
-      if (in_array($field, $this->getDeprecatedTokens(), TRUE)) {
-        $possibilities[] = array_search($field, $this->getDeprecatedTokens(), TRUE);
-      }
-    }
-
-    foreach ($possibilities as $possibility) {
-      if (isset($row->context[$entityName][$possibility])) {
-        return $row->context[$entityName][$possibility];
-      }
-    }
-    return '';
-  }
-
-  /**
-   * 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.
    *
@@ -463,153 +75,4 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
     }
   }
 
-  /**
-   * Get the contact for the row.
-   *
-   * @param int $contactId
-   * @param array $requiredFields
-   * @param bool $getAll
-   *
-   * @return array
-   * @throws \CRM_Core_Exception
-   */
-  protected function getContact(int $contactId, array $requiredFields, bool $getAll = FALSE): array {
-    $returnProperties = array_fill_keys($requiredFields, 1);
-    $mappedFields = array_flip($this->getDeprecatedTokens());
-
-    if (!empty($returnProperties['checksum'])) {
-      $returnProperties['hash'] = 1;
-    }
-
-    foreach ($mappedFields as $tokenName => $api3Name) {
-      if (in_array($tokenName, $requiredFields, TRUE)) {
-        $returnProperties[$api3Name] = 1;
-      }
-    }
-    if ($getAll) {
-      $returnProperties = array_merge($this->getAllContactReturnFields(), $returnProperties);
-    }
-
-    $params = [
-      ['contact_id', '=', $contactId, 0, 0],
-    ];
-    // @todo - map the parameters to apiv4 instead....
-    [$contact] = \CRM_Contact_BAO_Query::apiQuery($params, $returnProperties ?? NULL);
-    //CRM-4524
-    $contact = reset($contact);
-    foreach ($mappedFields as $tokenName => $apiv3Name) {
-      // it would be set already with the right value for a greeting token
-      // the query object returns the db value for email_greeting_display
-      // and a numeric value for email_greeting if you put email_greeting
-      // in the return properties.
-      if (!isset($contact[$tokenName])) {
-        $contact[$tokenName] = $contact[$apiv3Name] ?? '';
-      }
-    }
-
-    //update value of custom field token
-    foreach ($requiredFields as $token) {
-      if (\CRM_Core_BAO_CustomField::getKeyID($token)) {
-        $contact[$token] = \CRM_Core_BAO_CustomField::displayValue($contact[$token], \CRM_Core_BAO_CustomField::getKeyID($token));
-      }
-    }
-
-    return $contact;
-  }
-
-  /**
-   * Get the array of the return fields from 'get all'.
-   *
-   * This is the list from the BAO_Query object but copied
-   * here to be 'frozen in time'. The goal is to map to apiv4
-   * and stop using the legacy call to load the contact.
-   *
-   * @return array
-   */
-  protected function getAllContactReturnFields(): array {
-    return [
-      'image_URL' => 1,
-      'legal_identifier' => 1,
-      'external_identifier' => 1,
-      'contact_type' => 1,
-      'contact_sub_type' => 1,
-      'sort_name' => 1,
-      'display_name' => 1,
-      'preferred_mail_format' => 1,
-      'nick_name' => 1,
-      'first_name' => 1,
-      'middle_name' => 1,
-      'last_name' => 1,
-      'prefix_id' => 1,
-      'suffix_id' => 1,
-      'formal_title' => 1,
-      'communication_style_id' => 1,
-      'birth_date' => 1,
-      'gender_id' => 1,
-      'street_address' => 1,
-      'supplemental_address_1' => 1,
-      'supplemental_address_2' => 1,
-      'supplemental_address_3' => 1,
-      'city' => 1,
-      'postal_code' => 1,
-      'postal_code_suffix' => 1,
-      'state_province' => 1,
-      'country' => 1,
-      'world_region' => 1,
-      'geo_code_1' => 1,
-      'geo_code_2' => 1,
-      'email' => 1,
-      'on_hold' => 1,
-      'phone' => 1,
-      'im' => 1,
-      'household_name' => 1,
-      'organization_name' => 1,
-      'deceased_date' => 1,
-      'is_deceased' => 1,
-      'job_title' => 1,
-      'legal_name' => 1,
-      'sic_code' => 1,
-      'current_employer' => 1,
-      'do_not_email' => 1,
-      'do_not_mail' => 1,
-      'do_not_sms' => 1,
-      'do_not_phone' => 1,
-      'do_not_trade' => 1,
-      'is_opt_out' => 1,
-      'contact_is_deleted' => 1,
-      'preferred_communication_method' => 1,
-      'preferred_language' => 1,
-    ];
-  }
-
-  /**
-   * These tokens still work but we don't advertise them.
-   *
-   * We can remove from the following places
-   * - scheduled reminders
-   * - add to 'blocked' on pdf letter & email
-   *
-   * & then at some point start issuing warnings for them
-   * but contact tokens are pretty central so it might be
-   * a bit drawn out.
-   *
-   * @return string[]
-   *   Keys are deprecated tokens and values are their replacements.
-   */
-  protected function getDeprecatedTokens(): array {
-    return [
-      'individual_prefix' => 'prefix_id:label',
-      'individual_suffix' => 'suffix_id:label',
-      'gender' => 'gender_id:label',
-      'communication_style' => 'communication_style_id:label',
-      'preferred_communication_method' => 'preferred_communication_method:label',
-      'email_greeting' => 'email_greeting_display',
-      'postal_greeting' => 'postal_greeting_display',
-      'addressee' => 'addressee_display',
-      'contact_id' => 'id',
-      'contact_source' => 'source',
-      'contact_is_deleted' => 'is_deleted:label',
-    ];
-  }
-
 }
index c5bf9177f52e7793def32cfe11b3fc8927c998f9..40fe2878e180c1ea61d10f3a0cbd62d5f0e1f1ce 100644 (file)
@@ -178,6 +178,7 @@ class TokenProcessorTest extends \CiviUnitTestCase {
     $resetTime = \CRM_Utils_AutoClean::with(['CRM_Utils_Time', 'resetTime']);
     $this->dispatcher->addSubscriber(new \CRM_Core_DomainTokens());
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
     $p = new TokenProcessor($this->dispatcher, [
       'controller' => __CLASS__,
       'smarty' => TRUE,
@@ -203,10 +204,11 @@ class TokenProcessorTest extends \CiviUnitTestCase {
     $this->assertEquals(3, $rowCount);
   }
 
-  public function testRenderLocalizedHookToken() {
+  public function testRenderLocalizedHookToken(): void {
     $cid = $this->individualCreate();
 
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
     \Civi::dispatcher()->addListener('hook_civicrm_tokens', function($e) {
       $e->tokens['trans'] = [
         'trans.affirm' => ts('Translated affirmation'),
@@ -380,6 +382,8 @@ class TokenProcessorTest extends \CiviUnitTestCase {
     $cid = $this->individualCreate();
 
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
+
     \Civi::dispatcher()->addListener('hook_civicrm_tokens', function($e) {
       $e->tokens['fruit'] = [
         'fruit.apple' => ts('Apple'),
@@ -420,10 +424,11 @@ class TokenProcessorTest extends \CiviUnitTestCase {
   /**
    * Define extended tokens with funny symbols
    */
-  public function testHookTokenExtraChar() {
+  public function testHookTokenExtraChar(): void {
     $cid = $this->individualCreate();
 
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
     \Civi::dispatcher()->addListener('hook_civicrm_tokens', function ($e) {
       $e->tokens['food'] = [
         'food.fruit.apple' => ts('Apple'),
@@ -487,7 +492,7 @@ class TokenProcessorTest extends \CiviUnitTestCase {
   public function testMockData_ContactContribution() {
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
     $this->dispatcher->addSubscriber(new \CRM_Contribute_Tokens());
-
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
     $p = new TokenProcessor($this->dispatcher, [
       'controller' => __CLASS__,
       'schema' => ['contributionId', 'contactId'],
@@ -530,9 +535,10 @@ class TokenProcessorTest extends \CiviUnitTestCase {
   /**
    * Process a message using mocked data, accessed through a Smarty alias.
    */
-  public function testMockData_SmartyAlias_Contribution() {
+  public function testMockData_SmartyAlias_Contribution(): void {
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
     $this->dispatcher->addSubscriber(new \CRM_Contribute_Tokens());
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
 
     $p = new TokenProcessor($this->dispatcher, [
       'controller' => __CLASS__,
@@ -576,11 +582,12 @@ class TokenProcessorTest extends \CiviUnitTestCase {
    *
    * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field';
    */
-  public function testSmartyTokenAlias_Contribution() {
+  public function testSmartyTokenAlias_Contribution(): void {
     $first = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2010-01-01', 'invoice_id' => 100, 'trxn_id' => 1000]);
     $second = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2011-02-02', 'invoice_id' => 200, 'trxn_id' => 1]);
     $this->dispatcher->addSubscriber(new TokenCompatSubscriber());
     $this->dispatcher->addSubscriber(new \CRM_Contribute_Tokens());
+    $this->dispatcher->addSubscriber(new \CRM_Contact_Tokens());
 
     $p = new TokenProcessor($this->dispatcher, [
       'controller' => __CLASS__,