Consolidate building of contact token list
[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 ['onEvaluate'],
34 ],
35 'civi.token.render' => 'onRender',
36 'civi.token.list' => 'registerTokens',
37 ];
38 }
39
40 /**
41 * Register the declared tokens.
42 *
43 * @param \Civi\Token\Event\TokenRegisterEvent $e
44 * The registration event. Add new tokens using register().
45 */
46 public function registerTokens(TokenRegisterEvent $e): void {
47 if (!$this->checkActive($e->getTokenProcessor())) {
48 return;
49 }
50 foreach (array_merge($this->getContactTokens(), $this->getCustomFieldTokens()) as $name => $label) {
51 $e->register([
52 'entity' => $this->entity,
53 'field' => $name,
54 'label' => $label,
55 ]);
56 }
57 foreach ($this->getLegacyHookTokens() as $legacyHookToken) {
58 $e->register([
59 'entity' => $legacyHookToken['category'],
60 'field' => $legacyHookToken['name'],
61 'label' => $legacyHookToken['label'],
62 ]);
63 }
64 }
65
66 /**
67 * Determine whether this token-handler should be used with
68 * the given processor.
69 *
70 * To short-circuit token-processing in irrelevant contexts,
71 * override this.
72 *
73 * @param \Civi\Token\TokenProcessor $processor
74 * @return bool
75 */
76 public function checkActive(\Civi\Token\TokenProcessor $processor) {
77 return in_array($this->getEntityIDField(), $processor->context['schema'], TRUE);
78 }
79
80 /**
81 * @return string
82 */
83 public function getEntityIDField(): string {
84 return 'contactId';
85 }
86
87 /**
88 * Get functions declared using the legacy hook.
89 *
90 * Note that these only extend the contact entity (
91 * ie they are based on having a contact ID which they.
92 * may or may not use, but they don't have other
93 * entity IDs.)
94 *
95 * @return array
96 */
97 public function getLegacyHookTokens(): array {
98 $tokens = [];
99 $hookTokens = [];
100 \CRM_Utils_Hook::tokens($hookTokens);
101 foreach ($hookTokens as $tokenValues) {
102 foreach ($tokenValues as $key => $value) {
103 if (is_numeric($key)) {
104 // This appears to be an attempt to compensate for
105 // inconsistencies described in https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_tokenValues/#example
106 // in effect there is a suggestion that
107 // Send an Email" and "CiviMail" send different parameters to the tokenValues hook
108 // As of now 'send an email' renders hooks through this class.
109 // CiviMail it depends on the use or otherwise of flexmailer.
110 $key = $value;
111 }
112 if (preg_match('/^\{([^\}]+)\}$/', $value, $matches)) {
113 $value = $matches[1];
114 }
115 $keyParts = explode('.', $key);
116 $tokens[$key] = [
117 'category' => $keyParts[0],
118 'name' => $keyParts[1],
119 'label' => $value,
120 ];
121 }
122 }
123 return $tokens;
124 }
125
126 /**
127 * @return array
128 * @throws \CRM_Core_Exception
129 */
130 public function getCustomFieldTokens(): array {
131 $tokens = [];
132 $customFields = \CRM_Core_BAO_CustomField::getFields(['Individual', 'Address', 'Contact']);
133 foreach ($customFields as $customField) {
134 $tokens['custom_' . $customField['id']] = $customField['label'] . " :: " . $customField['groupTitle'];
135 }
136 return $tokens;
137 }
138
139 /**
140 * Get all tokens advertised as contact tokens.
141 *
142 * @return string[]
143 */
144 public function getContactTokens(): array {
145 return [
146 'contact_type' => 'Contact Type',
147 'do_not_email' => 'Do Not Email',
148 'do_not_phone' => 'Do Not Phone',
149 'do_not_mail' => 'Do Not Mail',
150 'do_not_sms' => 'Do Not Sms',
151 'do_not_trade' => 'Do Not Trade',
152 'is_opt_out' => 'No Bulk Emails (User Opt Out)',
153 'external_identifier' => 'External Identifier',
154 'sort_name' => 'Sort Name',
155 'display_name' => 'Display Name',
156 'nick_name' => 'Nickname',
157 'image_URL' => 'Image Url',
158 'preferred_communication_method' => 'Preferred Communication Method',
159 'preferred_language' => 'Preferred Language',
160 'preferred_mail_format' => 'Preferred Mail Format',
161 'hash' => 'Contact Hash',
162 'contact_source' => 'Contact Source',
163 'first_name' => 'First Name',
164 'middle_name' => 'Middle Name',
165 'last_name' => 'Last Name',
166 'individual_prefix' => 'Individual Prefix',
167 'individual_suffix' => 'Individual Suffix',
168 'formal_title' => 'Formal Title',
169 'communication_style' => 'Communication Style',
170 'job_title' => 'Job Title',
171 'gender' => 'Gender ID',
172 'birth_date' => 'Birth Date',
173 'current_employer_id' => 'Current Employer ID',
174 'contact_is_deleted' => 'Contact is in Trash',
175 'created_date' => 'Created Date',
176 'modified_date' => 'Modified Date',
177 'addressee' => 'Addressee',
178 'email_greeting' => 'Email Greeting',
179 'postal_greeting' => 'Postal Greeting',
180 'current_employer' => 'Current Employer',
181 'location_type' => 'Location Type',
182 'address_id' => 'Address ID',
183 'street_address' => 'Street Address',
184 'street_number' => 'Street Number',
185 'street_number_suffix' => 'Street Number Suffix',
186 'street_name' => 'Street Name',
187 'street_unit' => 'Street Unit',
188 'supplemental_address_1' => 'Supplemental Address 1',
189 'supplemental_address_2' => 'Supplemental Address 2',
190 'supplemental_address_3' => 'Supplemental Address 3',
191 'city' => 'City',
192 'postal_code_suffix' => 'Postal Code Suffix',
193 'postal_code' => 'Postal Code',
194 'geo_code_1' => 'Latitude',
195 'geo_code_2' => 'Longitude',
196 'manual_geo_code' => 'Is Manually Geocoded',
197 'address_name' => 'Address Name',
198 'master_id' => 'Master Address ID',
199 'county' => 'County',
200 'state_province' => 'State',
201 'country' => 'Country',
202 'phone' => 'Phone',
203 'phone_ext' => 'Phone Extension',
204 'phone_type_id' => 'Phone Type ID',
205 'phone_type' => 'Phone Type',
206 'email' => 'Email',
207 'on_hold' => 'On Hold',
208 'signature_text' => 'Signature Text',
209 'signature_html' => 'Signature Html',
210 'im_provider' => 'IM Provider',
211 'im' => 'IM Screen Name',
212 'openid' => 'OpenID',
213 'world_region' => 'World Region',
214 'url' => 'Website',
215 'checksum' => 'Checksum',
216 'contact_id' => 'Internal Contact ID',
217 ];
218 }
219
220 /**
221 * Interpret the variable `$context['smartyTokenAlias']` (e.g. `mySmartyField' => `tkn_entity.tkn_field`).
222 *
223 * We need to ensure that any tokens like `{tkn_entity.tkn_field}` are hydrated, so
224 * we pretend that they are in use.
225 *
226 * @param \Civi\Token\Event\TokenValueEvent $e
227 */
228 public function setupSmartyAliases(TokenValueEvent $e) {
229 $aliasedTokens = [];
230 foreach ($e->getRows() as $row) {
231 $aliasedTokens = array_unique(array_merge($aliasedTokens,
232 array_values($row->context['smartyTokenAlias'] ?? [])));
233 }
234
235 $fakeMessage = implode('', array_map(function ($f) {
236 return '{' . $f . '}';
237 }, $aliasedTokens));
238
239 $proc = $e->getTokenProcessor();
240 $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain');
241 }
242
243 /**
244 * Load token data.
245 *
246 * @param \Civi\Token\Event\TokenValueEvent $e
247 * @throws TokenException
248 */
249 public function onEvaluate(TokenValueEvent $e) {
250 // For reasons unknown, replaceHookTokens used to require a pre-computed list of
251 // hook *categories* (aka entities aka namespaces). We cache
252 // this in the TokenProcessor's context but can likely remove it now.
253
254 $e->getTokenProcessor()->context['hookTokenCategories'] = \CRM_Utils_Token::getTokenCategories();
255
256 $messageTokens = $e->getTokenProcessor()->getMessageTokens();
257 $returnProperties = array_fill_keys($messageTokens['contact'] ?? [], 1);
258 $returnProperties = array_merge(\CRM_Contact_BAO_Query::defaultReturnProperties(), $returnProperties);
259
260 foreach ($e->getRows() as $row) {
261 if (empty($row->context['contactId'])) {
262 continue;
263 }
264
265 unset($swapLocale);
266 $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
267
268 /** @var int $contactId */
269 $contactId = $row->context['contactId'];
270 if (empty($row->context['contact'])) {
271 $params = [
272 ['contact_id', '=', $contactId, 0, 0],
273 ];
274 [$contact] = \CRM_Contact_BAO_Query::apiQuery($params, $returnProperties ?? NULL);
275 //CRM-4524
276 $contact = reset($contact);
277 // Test cover for greeting in CRM_Core_BAO_ActionScheduleTest::testMailer
278 $contact['email_greeting'] = $contact['email_greeting_display'] ?? '';
279 $contact['postal_greeting'] = $contact['postal_greeting_display'] ?? '';
280 $contact['addressee'] = $contact['address_display'] ?? '';
281
282 //update value of custom field token
283 if (!empty($messageTokens['contact'])) {
284 foreach ($messageTokens['contact'] as $token) {
285 if (\CRM_Core_BAO_CustomField::getKeyID($token)) {
286 $contact[$token] = \CRM_Core_BAO_CustomField::displayValue($contact[$token], \CRM_Core_BAO_CustomField::getKeyID($token));
287 }
288 }
289 }
290 }
291 else {
292 $contact = $row->context['contact'];
293 }
294
295 $contactArray = [$contactId => $contact];
296 \CRM_Utils_Hook::tokenValues($contactArray,
297 [$contactId],
298 empty($row->context['mailingJobId']) ? NULL : $row->context['mailingJobId'],
299 $messageTokens,
300 $row->context['controller']
301 );
302
303 // merge the custom tokens in the $contact array
304 if (!empty($contactArray[$contactId])) {
305 $contact = array_merge($contact, $contactArray[$contactId]);
306 }
307 $row->context('contact', $contact);
308 }
309 }
310
311 /**
312 * Apply the various CRM_Utils_Token helpers.
313 *
314 * @param \Civi\Token\Event\TokenRenderEvent $e
315 */
316 public function onRender(TokenRenderEvent $e) {
317 $isHtml = ($e->message['format'] == 'text/html');
318 $useSmarty = !empty($e->context['smarty']);
319
320 $domain = \CRM_Core_BAO_Domain::getDomain();
321 $e->string = \CRM_Utils_Token::replaceDomainTokens($e->string, $domain, $isHtml, $e->message['tokens'], $useSmarty);
322
323 if (!empty($e->context['contact'])) {
324 \CRM_Utils_Token::replaceGreetingTokens($e->string, $e->context['contact'], $e->context['contact']['contact_id'], NULL, $useSmarty);
325 }
326
327 if ($useSmarty) {
328 $smartyVars = [];
329 foreach ($e->context['smartyTokenAlias'] ?? [] as $smartyName => $tokenName) {
330 // Note: $e->row->tokens resolves event-based tokens (eg CRM_*_Tokens). But if the target token relies on the
331 // above bits (replaceGreetingTokens=>replaceContactTokens=>replaceHookTokens) then this lookup isn't sufficient.
332 $smartyVars[$smartyName] = \CRM_Utils_Array::pathGet($e->row->tokens, explode('.', $tokenName));
333 }
334 \CRM_Core_Smarty::singleton()->pushScope($smartyVars);
335 try {
336 $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string);
337 }
338 finally {
339 \CRM_Core_Smarty::singleton()->popScope();
340 }
341 }
342 }
343
344 }