Merge pull request #19525 from eileenmcnaughton/member_soft
[civicrm-core.git] / CRM / Utils / Token.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
bc77d7c0 4 | Copyright CiviCRM LLC. All rights reserved. |
6a488035 5 | |
bc77d7c0
TO
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
6a488035 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
6a488035
TO
11
12/**
13 *
14 * @package CRM
ca5cec67 15 * @copyright CiviCRM LLC https://civicrm.org/licensing
6a488035
TO
16 */
17
18/**
b8c71ffa 19 * Class to abstract token replacement.
6a488035
TO
20 */
21class CRM_Utils_Token {
6714d8d2 22 public static $_requiredTokens = NULL;
6a488035 23
6714d8d2 24 public static $_tokens = [
be2fb01f 25 'action' => [
6a488035
TO
26 'forward',
27 'optOut',
28 'optOutUrl',
29 'reply',
30 'unsubscribe',
31 'unsubscribeUrl',
32 'resubscribe',
33 'resubscribeUrl',
34 'subscribeUrl',
be2fb01f
CW
35 ],
36 'mailing' => [
6a488035 37 'id',
b56b8b0e 38 'key',
6a488035
TO
39 'name',
40 'group',
41 'subject',
42 'viewUrl',
43 'editUrl',
44 'scheduleUrl',
45 'approvalStatus',
46 'approvalNote',
47 'approveUrl',
48 'creator',
49 'creatorEmail',
be2fb01f
CW
50 ],
51 'user' => [
6a488035
TO
52 // we extract the stuff after the role / permission and return the
53 // civicrm email addresses of all users with that role / permission
54 // useful with rules integration
55 'permission:',
56 'role:',
be2fb01f 57 ],
6a488035
TO
58 // populate this dynamically
59 'contact' => NULL,
60 // populate this dynamically
61 'contribution' => NULL,
be2fb01f 62 'domain' => [
6a488035
TO
63 'name',
64 'phone',
65 'address',
66 'email',
e3470b79 67 'id',
68 'description',
be2fb01f
CW
69 ],
70 'subscribe' => ['group'],
71 'unsubscribe' => ['group'],
72 'resubscribe' => ['group'],
73 'welcome' => ['group'],
74 ];
6a488035
TO
75
76 /**
dfbda5e5
TO
77 * @deprecated
78 * This is used by CiviMail but will be made redundant by FlexMailer.
5f56e085 79 * @return array
6a488035 80 */
5f56e085 81 public static function getRequiredTokens() {
6a488035 82 if (self::$_requiredTokens == NULL) {
be2fb01f 83 self::$_requiredTokens = [
6a488035 84 'domain.address' => ts("Domain address - displays your organization's postal address."),
be2fb01f 85 'action.optOutUrl or action.unsubscribeUrl' => [
6a488035
TO
86 'action.optOut' => ts("'Opt out via email' - displays an email address for recipients to opt out of receiving emails from your organization."),
87 'action.optOutUrl' => ts("'Opt out via web page' - creates a link for recipients to click if they want to opt out of receiving emails from your organization. Alternatively, you can include the 'Opt out via email' token."),
88 'action.unsubscribe' => ts("'Unsubscribe via email' - displays an email address for recipients to unsubscribe from the specific mailing list used to send this message."),
d72434db 89 'action.unsubscribeUrl' => ts("'Unsubscribe via web page' - creates a link for recipients to unsubscribe from the specific mailing list used to send this message. Alternatively, you can include the 'Unsubscribe via email' token or one of the Opt-out tokens."),
be2fb01f
CW
90 ],
91 ];
6a488035 92 }
5f56e085
TO
93 return self::$_requiredTokens;
94 }
95
96 /**
97 * Check a string (mailing body) for required tokens.
98 *
99 * @param string $str
100 * The message.
101 *
102 * @return bool|array
6714d8d2 103 * true if all required tokens are found,
5f56e085
TO
104 * else an array of the missing tokens
105 */
106 public static function requiredTokens(&$str) {
b845ba46 107 // FlexMailer is a refactoring of CiviMail which provides new hooks/APIs/docs. If the sysadmin has opted to enable it, then use that instead of CiviMail.
be2fb01f 108 $requiredTokens = defined('CIVICRM_FLEXMAILER_HACK_REQUIRED_TOKENS') ? Civi\Core\Resolver::singleton()->call(CIVICRM_FLEXMAILER_HACK_REQUIRED_TOKENS, []) : CRM_Utils_Token::getRequiredTokens();
6a488035 109
be2fb01f 110 $missing = [];
5f56e085 111 foreach ($requiredTokens as $token => $value) {
6a488035
TO
112 if (!is_array($value)) {
113 if (!preg_match('/(^|[^\{])' . preg_quote('{' . $token . '}') . '/', $str)) {
114 $missing[$token] = $value;
115 }
116 }
117 else {
118 $present = FALSE;
119 $desc = NULL;
120 foreach ($value as $t => $d) {
121 $desc = $d;
122 if (preg_match('/(^|[^\{])' . preg_quote('{' . $t . '}') . '/', $str)) {
123 $present = TRUE;
124 }
125 }
126 if (!$present) {
127 $missing[$token] = $desc;
128 }
129 }
130 }
131
132 if (empty($missing)) {
133 return TRUE;
134 }
135 return $missing;
136 }
137
138 /**
fe482240 139 * Wrapper for token matching.
6a488035 140 *
77855840
TO
141 * @param string $type
142 * The token type (domain,mailing,contact,action).
143 * @param string $var
144 * The token variable.
145 * @param string $str
146 * The string to search.
6a488035 147 *
e7483cbe 148 * @return bool
a6c01b45 149 * Was there a match
6a488035
TO
150 */
151 public static function token_match($type, $var, &$str) {
152 $token = preg_quote('{' . "$type.$var") . '(\|.+?)?' . preg_quote('}');
93f087c7 153 return preg_match("/(^|[^\{])$token/", $str);
6a488035
TO
154 }
155
156 /**
fe482240 157 * Wrapper for token replacing.
6a488035 158 *
77855840
TO
159 * @param string $type
160 * The token type.
161 * @param string $var
162 * The token variable.
163 * @param string $value
164 * The value to substitute for the token.
6714d8d2 165 * @param string $str (reference) The string to replace in
e39893f5
EM
166 *
167 * @param bool $escapeSmarty
6a488035 168 *
a6c01b45
CW
169 * @return string
170 * The processed string
6a488035 171 */
40022216 172 public static function token_replace($type, $var, $value, &$str, $escapeSmarty = FALSE) {
6a488035
TO
173 $token = preg_quote('{' . "$type.$var") . '(\|([^\}]+?))?' . preg_quote('}');
174 if (!$value) {
175 $value = '$3';
176 }
177 if ($escapeSmarty) {
178 $value = self::tokenEscapeSmarty($value);
179 }
180 $str = preg_replace("/([^\{])?$token/", "\${1}$value", $str);
181 return $str;
182 }
183
184 /**
100fef9d 185 * Get< the regex for token replacement
6a488035 186 *
77855840
TO
187 * @param string $token_type
188 * A string indicating the the type of token to be used in the expression.
6a488035 189 *
a6c01b45
CW
190 * @return string
191 * regular expression sutiable for using in preg_replace
6a488035
TO
192 */
193 private static function tokenRegex($token_type) {
8bab0eb0 194 return '/(?<!\{|\\\\)\{' . $token_type . '\.([\w]+(\-[\w\s]+)?)\}(?!\})/';
6a488035
TO
195 }
196
197 /**
fe482240 198 * Escape the string so a malicious user cannot inject smarty code into the template.
6a488035 199 *
77855840
TO
200 * @param string $string
201 * A string that needs to be escaped from smarty parsing.
6a488035 202 *
a6c01b45
CW
203 * @return string
204 * the escaped string
6a488035 205 */
43ceab3f 206 public static function tokenEscapeSmarty($string) {
6a488035 207 // need to use negative look-behind, as both str_replace() and preg_replace() are sequential
be2fb01f 208 return preg_replace(['/{/', '/(?<!{ldelim)}/'], ['{ldelim}', '{rdelim}'], $string);
6a488035
TO
209 }
210
e7292422 211 /**
6a488035
TO
212 * Replace all the domain-level tokens in $str
213 *
77855840
TO
214 * @param string $str
215 * The string with tokens to be replaced.
216 * @param object $domain
217 * The domain BAO.
218 * @param bool $html
219 * Replace tokens with HTML or plain text.
e39893f5
EM
220 *
221 * @param null $knownTokens
222 * @param bool $escapeSmarty
6a488035 223 *
a6c01b45
CW
224 * @return string
225 * The processed string
6a488035 226 */
17aa5355 227 public static function replaceDomainTokens(
21bb6c7b 228 $str,
17aa5355 229 $domain,
21bb6c7b
DL
230 $html = FALSE,
231 $knownTokens = NULL,
232 $escapeSmarty = FALSE
233 ) {
6a488035 234 $key = 'domain';
21bb6c7b 235 if (
353ffa53
TO
236 !$knownTokens || empty($knownTokens[$key])
237 ) {
6a488035
TO
238 return $str;
239 }
240
8bab0eb0 241 $str = preg_replace_callback(
21bb6c7b 242 self::tokenRegex($key),
17aa5355 243 function ($matches) use ($domain, $html, $escapeSmarty) {
353ffa53 244 return CRM_Utils_Token::getDomainTokenReplacement($matches[1], $domain, $html, $escapeSmarty);
8bab0eb0 245 },
21bb6c7b
DL
246 $str
247 );
6a488035
TO
248 return $str;
249 }
250
e39893f5 251 /**
160e7328 252 * @param string $token
d357f225 253 * @param CRM_Core_BAO_Domain $domain
e39893f5
EM
254 * @param bool $html
255 * @param bool $escapeSmarty
256 *
160e7328 257 * @return null|string
e39893f5 258 */
160e7328 259 public static function getDomainTokenReplacement($token, $domain, $html = FALSE, $escapeSmarty = FALSE): ?string {
6a488035
TO
260 // check if the token we were passed is valid
261 // we have to do this because this function is
262 // called only when we find a token in the string
263
d357f225 264 $loc = $domain->getLocationValues();
6a488035
TO
265
266 if (!in_array($token, self::$_tokens['domain'])) {
267 $value = "{domain.$token}";
268 }
160e7328 269 elseif ($token === 'address') {
270 $cacheKey = __CLASS__ . 'address_token_cache' . CRM_Core_Config::domainID();
271 $addressCache = Civi::cache()->has($cacheKey) ? Civi::cache()->get($cacheKey) : [];
6a488035 272
160e7328 273 $fieldKey = $html ? 'address-html' : 'address-text';
274 if (array_key_exists($fieldKey, $addressCache)) {
275 return $addressCache[$fieldKey];
6a488035
TO
276 }
277
278 $value = NULL;
50bfb460 279 // Construct the address token
6a488035 280
a7488080 281 if (!empty($loc[$token])) {
6a488035 282 if ($html) {
160e7328 283 $value = str_replace("\n", '<br />', $loc[$token][1]['display']);
6a488035
TO
284 }
285 else {
286 $value = $loc[$token][1]['display_text'];
287 }
160e7328 288 Civi::cache()->set($cacheKey, $addressCache);
6a488035
TO
289 }
290 }
160e7328 291 elseif ($token === 'name' || $token === 'id' || $token === 'description') {
6a488035
TO
292 $value = $domain->$token;
293 }
160e7328 294 elseif ($token === 'phone' || $token === 'email') {
50bfb460 295 // Construct the phone and email tokens
6a488035
TO
296
297 $value = NULL;
a7488080 298 if (!empty($loc[$token])) {
6a488035
TO
299 foreach ($loc[$token] as $index => $entity) {
300 $value = $entity[$token];
301 break;
302 }
303 }
304 }
305
306 if ($escapeSmarty) {
307 $value = self::tokenEscapeSmarty($value);
308 }
309
310 return $value;
311 }
312
313 /**
314 * Replace all the org-level tokens in $str
315 *
c21a9b7f
CW
316 * @fixme: This function appears to be broken, as it depends on
317 * nonexistant method: CRM_Core_BAO_CustomValue::getContactValues()
318 * Marking as deprecated until this is fixed
319 * @deprecated
320 *
77855840
TO
321 * @param string $str
322 * The string with tokens to be replaced.
323 * @param object $org
324 * Associative array of org properties.
325 * @param bool $html
326 * Replace tokens with HTML or plain text.
e39893f5
EM
327 *
328 * @param bool $escapeSmarty
6a488035 329 *
a6c01b45
CW
330 * @return string
331 * The processed string
6a488035
TO
332 */
333 public static function &replaceOrgTokens($str, &$org, $html = FALSE, $escapeSmarty = FALSE) {
e7483cbe
J
334 self::$_tokens['org']
335 = array_merge(
21bb6c7b 336 array_keys(CRM_Contact_BAO_Contact::importableFields('Organization')),
be2fb01f 337 ['address', 'display_name', 'checksum', 'contact_id']
21bb6c7b 338 );
6a488035
TO
339
340 $cv = NULL;
341 foreach (self::$_tokens['org'] as $token) {
342 // print "Getting token value for $token<br/><br/>";
343 if ($token == '') {
344 continue;
345 }
346
50bfb460 347 // If the string doesn't contain this token, skip it.
6a488035
TO
348
349 if (!self::token_match('org', $token, $str)) {
350 continue;
351 }
352
50bfb460 353 // Construct value from $token and $contact
6a488035
TO
354
355 $value = NULL;
356
357 if ($cfID = CRM_Core_BAO_CustomField::getKeyID($token)) {
358 // only generate cv if we need it
359 if ($cv === NULL) {
360 $cv = CRM_Core_BAO_CustomValue::getContactValues($org['contact_id']);
361 }
362 foreach ($cv as $cvFieldID => $value) {
363 if ($cvFieldID == $cfID) {
c21a9b7f 364 $value = CRM_Core_BAO_CustomField::displayValue($value, $cfID);
6a488035
TO
365 break;
366 }
367 }
368 }
369 elseif ($token == 'checksum') {
370 $cs = CRM_Contact_BAO_Contact_Utils::generateChecksum($org['contact_id']);
371 $value = "cs={$cs}";
372 }
373 elseif ($token == 'address') {
50bfb460 374 // Build the location values array
6a488035 375
be2fb01f 376 $loc = [];
6a488035
TO
377 $loc['display_name'] = CRM_Utils_Array::retrieveValueRecursive($org, 'display_name');
378 $loc['street_address'] = CRM_Utils_Array::retrieveValueRecursive($org, 'street_address');
379 $loc['city'] = CRM_Utils_Array::retrieveValueRecursive($org, 'city');
380 $loc['state_province'] = CRM_Utils_Array::retrieveValueRecursive($org, 'state_province');
381 $loc['postal_code'] = CRM_Utils_Array::retrieveValueRecursive($org, 'postal_code');
382
50bfb460 383 // Construct the address token
6a488035
TO
384
385 $value = CRM_Utils_Address::format($loc);
386 if ($html) {
387 $value = str_replace("\n", '<br />', $value);
388 }
389 }
390 else {
391 $value = CRM_Utils_Array::retrieveValueRecursive($org, $token);
392 }
393
394 self::token_replace('org', $token, $value, $str, $escapeSmarty);
395 }
396
397 return $str;
398 }
399
400 /**
401 * Replace all mailing tokens in $str
402 *
77855840
TO
403 * @param string $str
404 * The string with tokens to be replaced.
405 * @param object $mailing
406 * The mailing BAO, or null for validation.
407 * @param bool $html
408 * Replace tokens with HTML or plain text.
e39893f5
EM
409 *
410 * @param null $knownTokens
411 * @param bool $escapeSmarty
6a488035 412 *
a6c01b45 413 * @return string
50bfb460 414 * The processed string
6a488035 415 */
21bb6c7b
DL
416 public static function &replaceMailingTokens(
417 $str,
418 &$mailing,
419 $html = FALSE,
420 $knownTokens = NULL,
421 $escapeSmarty = FALSE
422 ) {
6a488035
TO
423 $key = 'mailing';
424 if (!$knownTokens || !isset($knownTokens[$key])) {
425 return $str;
426 }
427
8bab0eb0 428 $str = preg_replace_callback(
6a488035 429 self::tokenRegex($key),
353ffa53
TO
430 function ($matches) use (&$mailing, $escapeSmarty) {
431 return CRM_Utils_Token::getMailingTokenReplacement($matches[1], $mailing, $escapeSmarty);
8bab0eb0
DL
432 },
433 $str
6a488035
TO
434 );
435 return $str;
436 }
437
e39893f5
EM
438 /**
439 * @param $token
440 * @param $mailing
441 * @param bool $escapeSmarty
442 *
443 * @return string
444 */
6a488035
TO
445 public static function getMailingTokenReplacement($token, &$mailing, $escapeSmarty = FALSE) {
446 $value = '';
447 switch ($token) {
448 // CRM-7663
449
450 case 'id':
451 $value = $mailing ? $mailing->id : 'undefined';
452 break;
453
b56b8b0e
J
454 // Key is the ID, or the hash when the hash URLs setting is enabled
455 case 'key':
456 $value = $mailing->id;
457 if ($hash = CRM_Mailing_BAO_Mailing::getMailingHash($value)) {
458 $value = $hash;
459 }
460 break;
6a488035
TO
461
462 case 'name':
463 $value = $mailing ? $mailing->name : 'Mailing Name';
464 break;
465
466 case 'group':
be2fb01f 467 $groups = $mailing ? $mailing->getGroupNames() : ['Mailing Groups'];
6a488035
TO
468 $value = implode(', ', $groups);
469 break;
470
471 case 'subject':
472 $value = $mailing->subject;
473 break;
474
475 case 'viewUrl':
c57f36a1
PJ
476 $mailingKey = $mailing->id;
477 if ($hash = CRM_Mailing_BAO_Mailing::getMailingHash($mailingKey)) {
478 $mailingKey = $hash;
479 }
6a488035 480 $value = CRM_Utils_System::url('civicrm/mailing/view',
c57f36a1 481 "reset=1&id={$mailingKey}",
6a488035
TO
482 TRUE, NULL, FALSE, TRUE
483 );
484 break;
485
486 case 'editUrl':
0083c73f
TO
487 case 'scheduleUrl':
488 // Note: editUrl and scheduleUrl used to be different, but now there's
489 // one screen which can adapt based on permissions (in workflow mode).
6a488035
TO
490 $value = CRM_Utils_System::url('civicrm/mailing/send',
491 "reset=1&mid={$mailing->id}&continue=true",
492 TRUE, NULL, FALSE, TRUE
493 );
494 break;
495
6a488035
TO
496 case 'html':
497 $page = new CRM_Mailing_Page_View();
c57f36a1 498 $value = $page->run($mailing->id, NULL, FALSE, TRUE);
6a488035
TO
499 break;
500
501 case 'approvalStatus':
a8c23526 502 $value = CRM_Core_PseudoConstant::getLabel('CRM_Mailing_DAO_Mailing', 'approval_status_id', $mailing->approval_status_id);
6a488035
TO
503 break;
504
505 case 'approvalNote':
506 $value = $mailing->approval_note;
507 break;
508
509 case 'approveUrl':
510 $value = CRM_Utils_System::url('civicrm/mailing/approve',
511 "reset=1&mid={$mailing->id}",
512 TRUE, NULL, FALSE, TRUE
513 );
514 break;
515
516 case 'creator':
517 $value = CRM_Contact_BAO_Contact::displayName($mailing->created_id);
518 break;
519
520 case 'creatorEmail':
521 $value = CRM_Contact_BAO_Contact::getPrimaryEmail($mailing->created_id);
522 break;
523
524 default:
525 $value = "{mailing.$token}";
526 break;
527 }
528
529 if ($escapeSmarty) {
530 $value = self::tokenEscapeSmarty($value);
531 }
532 return $value;
533 }
534
535 /**
536 * Replace all action tokens in $str
537 *
77855840
TO
538 * @param string $str
539 * The string with tokens to be replaced.
540 * @param array $addresses
541 * Assoc. array of VERP event addresses.
542 * @param array $urls
543 * Assoc. array of action URLs.
544 * @param bool $html
545 * Replace tokens with HTML or plain text.
546 * @param array $knownTokens
547 * A list of tokens that are known to exist in the email body.
e39893f5
EM
548 *
549 * @param bool $escapeSmarty
6a488035 550 *
a6c01b45
CW
551 * @return string
552 * The processed string
6a488035
TO
553 */
554 public static function &replaceActionTokens(
555 $str,
556 &$addresses,
557 &$urls,
558 $html = FALSE,
559 $knownTokens = NULL,
560 $escapeSmarty = FALSE
561 ) {
562 $key = 'action';
563 // here we intersect with the list of pre-configured valid tokens
564 // so that we remove anything we do not recognize
565 // I hope to move this step out of here soon and
566 // then we will just iterate on a list of tokens that are passed to us
8cc574cf 567 if (!$knownTokens || empty($knownTokens[$key])) {
6a488035
TO
568 return $str;
569 }
570
8bab0eb0
DL
571 $str = preg_replace_callback(
572 self::tokenRegex($key),
353ffa53
TO
573 function ($matches) use (&$addresses, &$urls, $html, $escapeSmarty) {
574 return CRM_Utils_Token::getActionTokenReplacement($matches[1], $addresses, $urls, $html, $escapeSmarty);
8bab0eb0 575 },
6a488035
TO
576 $str
577 );
578 return $str;
579 }
580
e39893f5
EM
581 /**
582 * @param $token
583 * @param $addresses
584 * @param $urls
585 * @param bool $html
586 * @param bool $escapeSmarty
587 *
588 * @return mixed|string
589 */
21bb6c7b
DL
590 public static function getActionTokenReplacement(
591 $token,
592 &$addresses,
593 &$urls,
594 $html = FALSE,
595 $escapeSmarty = FALSE
596 ) {
50bfb460
SB
597 // If the token is an email action, use it. Otherwise, find the
598 // appropriate URL.
6a488035
TO
599
600 if (!in_array($token, self::$_tokens['action'])) {
601 $value = "{action.$token}";
602 }
603 else {
9c1bc317 604 $value = $addresses[$token] ?? NULL;
6a488035
TO
605
606 if ($value == NULL) {
9c1bc317 607 $value = $urls[$token] ?? NULL;
6a488035
TO
608 }
609
610 if ($value && $html) {
50bfb460 611 // fix for CRM-2318
6a488035
TO
612 if ((substr($token, -3) != 'Url') && ($token != 'forward')) {
613 $value = "mailto:$value";
614 }
615 }
616 elseif ($value && !$html) {
617 $value = str_replace('&amp;', '&', $value);
618 }
619 }
620
621 if ($escapeSmarty) {
622 $value = self::tokenEscapeSmarty($value);
623 }
624 return $value;
625 }
626
627 /**
628 * Replace all the contact-level tokens in $str with information from
629 * $contact.
630 *
77855840
TO
631 * @param string $str
632 * The string with tokens to be replaced.
633 * @param array $contact
634 * Associative array of contact properties.
635 * @param bool $html
636 * Replace tokens with HTML or plain text.
637 * @param array $knownTokens
638 * A list of tokens that are known to exist in the email body.
639 * @param bool $returnBlankToken
640 * Return unevaluated token if value is null.
e39893f5
EM
641 *
642 * @param bool $escapeSmarty
6a488035 643 *
a6c01b45
CW
644 * @return string
645 * The processed string
6a488035 646 */
17aa5355 647 public static function replaceContactTokens(
21bb6c7b
DL
648 $str,
649 &$contact,
650 $html = FALSE,
651 $knownTokens = NULL,
652 $returnBlankToken = FALSE,
653 $escapeSmarty = FALSE
6a488035 654 ) {
7c990617 655 // Refresh contact tokens in case they have changed. There is heavy caching
656 // in exportable fields so there is no benefit in doing this conditionally.
657 self::$_tokens['contact'] = array_merge(
658 array_keys(CRM_Contact_BAO_Contact::exportableFields('All')),
be2fb01f 659 ['checksum', 'contact_id']
7c990617 660 );
6a488035 661
7c990617 662 $key = 'contact';
6a488035
TO
663 // here we intersect with the list of pre-configured valid tokens
664 // so that we remove anything we do not recognize
665 // I hope to move this step out of here soon and
666 // then we will just iterate on a list of tokens that are passed to us
8cc574cf 667 if (!$knownTokens || empty($knownTokens[$key])) {
6a488035
TO
668 return $str;
669 }
670
8bab0eb0 671 $str = preg_replace_callback(
21bb6c7b 672 self::tokenRegex($key),
353ffa53
TO
673 function ($matches) use (&$contact, $html, $returnBlankToken, $escapeSmarty) {
674 return CRM_Utils_Token::getContactTokenReplacement($matches[1], $contact, $html, $returnBlankToken, $escapeSmarty);
8bab0eb0 675 },
6a488035
TO
676 $str
677 );
678
679 $str = preg_replace('/\\\\|\{(\s*)?\}/', ' ', $str);
680 return $str;
681 }
682
e39893f5
EM
683 /**
684 * @param $token
685 * @param $contact
686 * @param bool $html
687 * @param bool $returnBlankToken
688 * @param bool $escapeSmarty
689 *
690 * @return bool|mixed|null|string
691 */
21bb6c7b
DL
692 public static function getContactTokenReplacement(
693 $token,
694 &$contact,
695 $html = FALSE,
696 $returnBlankToken = FALSE,
697 $escapeSmarty = FALSE
6a488035
TO
698 ) {
699 if (self::$_tokens['contact'] == NULL) {
700 /* This should come from UF */
701
e7483cbe
J
702 self::$_tokens['contact']
703 = array_merge(
21bb6c7b 704 array_keys(CRM_Contact_BAO_Contact::exportableFields('All')),
be2fb01f 705 ['checksum', 'contact_id']
21bb6c7b 706 );
6a488035
TO
707 }
708
50bfb460 709 // Construct value from $token and $contact
6a488035
TO
710
711 $value = NULL;
73d64eb6 712 $noReplace = FALSE;
6a488035 713
091133bb
CW
714 // Support legacy tokens
715 $token = CRM_Utils_Array::value($token, self::legacyContactTokens(), $token);
716
6a488035
TO
717 // check if the token we were passed is valid
718 // we have to do this because this function is
719 // called only when we find a token in the string
720
721 if (!in_array($token, self::$_tokens['contact'])) {
73d64eb6 722 $noReplace = TRUE;
6a488035
TO
723 }
724 elseif ($token == 'checksum') {
9c1bc317 725 $hash = $contact['hash'] ?? NULL;
6a488035
TO
726 $contactID = CRM_Utils_Array::retrieveValueRecursive($contact, 'contact_id');
727 $cs = CRM_Contact_BAO_Contact_Utils::generateChecksum($contactID,
728 NULL,
729 NULL,
730 $hash
731 );
732 $value = "cs={$cs}";
733 }
734 else {
735 $value = CRM_Utils_Array::retrieveValueRecursive($contact, $token);
ee70f332 736
091133bb 737 // FIXME: for some pseudoconstants we get array ( 0 => id, 1 => label )
ee70f332 738 if (is_array($value)) {
739 $value = $value[1];
740 }
091133bb
CW
741 // Convert pseudoconstants using metadata
742 elseif ($value && is_numeric($value)) {
743 $allFields = CRM_Contact_BAO_Contact::exportableFields('All');
744 if (!empty($allFields[$token]['pseudoconstant'])) {
745 $value = CRM_Core_PseudoConstant::getLabel('CRM_Contact_BAO_Contact', $token, $value);
746 }
747 }
7ab8b45b 748 elseif ($value && CRM_Utils_String::endsWith($token, '_date')) {
749 $value = CRM_Utils_Date::customFormat($value);
750 }
6a488035
TO
751 }
752
753 if (!$html) {
754 $value = str_replace('&amp;', '&', $value);
755 }
756
757 // if null then return actual token
758 if ($returnBlankToken && !$value) {
73d64eb6
OB
759 $noReplace = TRUE;
760 }
761
762 if ($noReplace) {
6a488035
TO
763 $value = "{contact.$token}";
764 }
765
73d64eb6 766 if ($escapeSmarty
353ffa53 767 && !($returnBlankToken && $noReplace)
e7483cbe
J
768 ) {
769 // $returnBlankToken means the caller wants to do further attempts at
770 // processing unreplaced tokens -- so don't escape them yet in this case.
6a488035
TO
771 $value = self::tokenEscapeSmarty($value);
772 }
773
774 return $value;
775 }
776
777 /**
778 * Replace all the hook tokens in $str with information from
779 * $contact.
780 *
77855840
TO
781 * @param string $str
782 * The string with tokens to be replaced.
783 * @param array $contact
784 * Associative array of contact properties (including hook token values).
e39893f5 785 * @param $categories
77855840
TO
786 * @param bool $html
787 * Replace tokens with HTML or plain text.
e39893f5
EM
788 *
789 * @param bool $escapeSmarty
6a488035 790 *
a6c01b45
CW
791 * @return string
792 * The processed string
6a488035 793 */
21bb6c7b
DL
794 public static function &replaceHookTokens(
795 $str,
796 &$contact,
797 &$categories,
798 $html = FALSE,
799 $escapeSmarty = FALSE
800 ) {
6a488035 801 foreach ($categories as $key) {
8bab0eb0 802 $str = preg_replace_callback(
21bb6c7b 803 self::tokenRegex($key),
353ffa53
TO
804 function ($matches) use (&$contact, $key, $html, $escapeSmarty) {
805 return CRM_Utils_Token::getHookTokenReplacement($matches[1], $contact, $key, $html, $escapeSmarty);
8bab0eb0 806 },
6a488035
TO
807 $str
808 );
809 }
810 return $str;
811 }
812
2d3e3c7b 813 /**
fe482240 814 * Parse html through Smarty resolving any smarty functions.
2d3e3c7b 815 * @param string $tokenHtml
816 * @param array $entity
817 * @param string $entityType
a6c01b45
CW
818 * @return string
819 * html parsed through smarty
2d3e3c7b 820 */
821 public static function parseThroughSmarty($tokenHtml, $entity, $entityType = 'contact') {
822 if (defined('CIVICRM_MAIL_SMARTY') && CIVICRM_MAIL_SMARTY) {
823 $smarty = CRM_Core_Smarty::singleton();
824 // also add the tokens to the template
825 $smarty->assign_by_ref($entityType, $entity);
826 $tokenHtml = $smarty->fetch("string:$tokenHtml");
827 }
828 return $tokenHtml;
829 }
5bc392e6
EM
830
831 /**
832 * @param $token
833 * @param $contact
834 * @param $category
835 * @param bool $html
836 * @param bool $escapeSmarty
837 *
838 * @return mixed|string
95ea96be
EM
839 */
840 public static function getHookTokenReplacement(
21bb6c7b
DL
841 $token,
842 &$contact,
843 $category,
844 $html = FALSE,
845 $escapeSmarty = FALSE
846 ) {
9c1bc317 847 $value = $contact["{$category}.{$token}"] ?? NULL;
6a488035 848
21bb6c7b 849 if ($value && !$html) {
6a488035
TO
850 $value = str_replace('&amp;', '&', $value);
851 }
852
853 if ($escapeSmarty) {
854 $value = self::tokenEscapeSmarty($value);
855 }
856
857 return $value;
858 }
859
860 /**
50bfb460
SB
861 * unescapeTokens removes any characters that caused the replacement routines to skip token replacement
862 * for example {{token}} or \{token} will result in {token} in the final email
6a488035 863 *
50bfb460 864 * this routine will remove the extra backslashes and braces
6a488035 865 *
e7292422 866 * @param $str ref to the string that will be scanned and modified
6a488035
TO
867 */
868 public static function unescapeTokens(&$str) {
869 $str = preg_replace('/\\\\|\{(\{\w+\.\w+\})\}/', '\\1', $str);
870 }
871
872 /**
fe482240 873 * Replace unsubscribe tokens.
6a488035 874 *
77855840
TO
875 * @param string $str
876 * The string with tokens to be replaced.
877 * @param object $domain
878 * The domain BAO.
879 * @param array $groups
880 * The groups (if any) being unsubscribed.
881 * @param bool $html
882 * Replace tokens with html or plain text.
883 * @param int $contact_id
884 * The contact ID.
e7483cbe 885 * @param string $hash The security hash of the unsub event
6a488035 886 *
a6c01b45
CW
887 * @return string
888 * The processed string
6a488035
TO
889 */
890 public static function &replaceUnsubscribeTokens(
891 $str,
892 &$domain,
893 &$groups,
894 $html,
895 $contact_id,
896 $hash
897 ) {
898 if (self::token_match('unsubscribe', 'group', $str)) {
899 if (!empty($groups)) {
900 $config = CRM_Core_Config::singleton();
901 $base = CRM_Utils_System::baseURL();
902
903 // FIXME: an ugly hack for CRM-2035, to be dropped once CRM-1799 is implemented
904 $dao = new CRM_Contact_DAO_Group();
905 $dao->find();
906 while ($dao->fetch()) {
907 if (substr($dao->visibility, 0, 6) == 'Public') {
908 $visibleGroups[] = $dao->id;
909 }
910 }
911 $value = implode(', ', $groups);
912 self::token_replace('unsubscribe', 'group', $value, $str);
913 }
914 }
915 return $str;
916 }
917
918 /**
fe482240 919 * Replace resubscribe tokens.
6a488035 920 *
77855840
TO
921 * @param string $str
922 * The string with tokens to be replaced.
923 * @param object $domain
924 * The domain BAO.
925 * @param array $groups
926 * The groups (if any) being resubscribed.
927 * @param bool $html
928 * Replace tokens with html or plain text.
929 * @param int $contact_id
930 * The contact ID.
e7483cbe 931 * @param string $hash The security hash of the resub event
6a488035 932 *
a6c01b45
CW
933 * @return string
934 * The processed string
6a488035 935 */
a3e55d9c
TO
936 public static function &replaceResubscribeTokens(
937 $str, &$domain, &$groups, $html,
6a488035
TO
938 $contact_id, $hash
939 ) {
940 if (self::token_match('resubscribe', 'group', $str)) {
941 if (!empty($groups)) {
942 $value = implode(', ', $groups);
943 self::token_replace('resubscribe', 'group', $value, $str);
944 }
945 }
946 return $str;
947 }
948
949 /**
950 * Replace subscription-confirmation-request tokens
951 *
77855840
TO
952 * @param string $str
953 * The string with tokens to be replaced.
954 * @param string $group
955 * The name of the group being subscribed.
e39893f5 956 * @param $url
77855840
TO
957 * @param bool $html
958 * Replace tokens with html or plain text.
6a488035 959 *
a6c01b45
CW
960 * @return string
961 * The processed string
6a488035
TO
962 */
963 public static function &replaceSubscribeTokens($str, $group, $url, $html) {
964 if (self::token_match('subscribe', 'group', $str)) {
965 self::token_replace('subscribe', 'group', $group, $str);
966 }
967 if (self::token_match('subscribe', 'url', $str)) {
968 self::token_replace('subscribe', 'url', $url, $str);
969 }
970 return $str;
971 }
972
973 /**
974 * Replace subscription-invitation tokens
975 *
77855840
TO
976 * @param string $str
977 * The string with tokens to be replaced.
6a488035 978 *
a6c01b45
CW
979 * @return string
980 * The processed string
6a488035
TO
981 */
982 public static function &replaceSubscribeInviteTokens($str) {
983 if (preg_match('/\{action\.subscribeUrl\}/', $str)) {
984 $url = CRM_Utils_System::url('civicrm/mailing/subscribe',
985 'reset=1',
3f390aa6 986 TRUE, NULL, FALSE, TRUE
6a488035
TO
987 );
988 $str = preg_replace('/\{action\.subscribeUrl\}/', $url, $str);
989 }
990
991 if (preg_match('/\{action\.subscribeUrl.\d+\}/', $str, $matches)) {
992 foreach ($matches as $key => $value) {
993 $gid = substr($value, 21, -1);
994 $url = CRM_Utils_System::url('civicrm/mailing/subscribe',
995 "reset=1&gid={$gid}",
3f390aa6 996 TRUE, NULL, FALSE, TRUE
6a488035 997 );
6a488035
TO
998 $str = preg_replace('/' . preg_quote($value) . '/', $url, $str);
999 }
1000 }
1001
1002 if (preg_match('/\{action\.subscribe.\d+\}/', $str, $matches)) {
1003 foreach ($matches as $key => $value) {
353ffa53
TO
1004 $gid = substr($value, 18, -1);
1005 $config = CRM_Core_Config::singleton();
1006 $domain = CRM_Core_BAO_MailSettings::defaultDomain();
6a488035
TO
1007 $localpart = CRM_Core_BAO_MailSettings::defaultLocalpart();
1008 // we add the 0.0000000000000000 part to make this match the other email patterns (with action, two ids and a hash)
1009 $str = preg_replace('/' . preg_quote($value) . '/', "mailto:{$localpart}s.{$gid}.0.0000000000000000@$domain", $str);
1010 }
1011 }
1012 return $str;
1013 }
1014
1015 /**
1016 * Replace welcome/confirmation tokens
1017 *
77855840
TO
1018 * @param string $str
1019 * The string with tokens to be replaced.
1020 * @param string $group
1021 * The name of the group being subscribed.
1022 * @param bool $html
1023 * Replace tokens with html or plain text.
6a488035 1024 *
a6c01b45
CW
1025 * @return string
1026 * The processed string
6a488035
TO
1027 */
1028 public static function &replaceWelcomeTokens($str, $group, $html) {
1029 if (self::token_match('welcome', 'group', $str)) {
1030 self::token_replace('welcome', 'group', $group, $str);
1031 }
1032 return $str;
1033 }
1034
1035 /**
1036 * Find unprocessed tokens (call this last)
1037 *
77855840
TO
1038 * @param string $str
1039 * The string to search.
6a488035 1040 *
a6c01b45
CW
1041 * @return array
1042 * Array of tokens that weren't replaced
6a488035
TO
1043 */
1044 public static function &unmatchedTokens(&$str) {
1045 //preg_match_all('/[^\{\\\\]\{(\w+\.\w+)\}[^\}]/', $str, $match);
1046 preg_match_all('/\{(\w+\.\w+)\}/', $str, $match);
1047 return $match[1];
1048 }
1049
1050 /**
fe482240 1051 * Find and replace tokens for each component.
6a488035 1052 *
77855840
TO
1053 * @param string $str
1054 * The string to search.
1055 * @param array $contact
1056 * Associative array of contact properties.
1057 * @param array $components
1058 * A list of tokens that are known to exist in the email body.
6a488035 1059 *
e39893f5
EM
1060 * @param bool $escapeSmarty
1061 * @param bool $returnEmptyToken
1062 *
a6c01b45
CW
1063 * @return string
1064 * The processed string
6a488035
TO
1065 */
1066 public static function &replaceComponentTokens(&$str, $contact, $components, $escapeSmarty = FALSE, $returnEmptyToken = TRUE) {
1067 if (!is_array($components) || empty($contact)) {
1068 return $str;
1069 }
1070
1071 foreach ($components as $name => $tokens) {
1072 if (!is_array($tokens) || empty($tokens)) {
1073 continue;
1074 }
1075
1076 foreach ($tokens as $token) {
1077 if (self::token_match($name, $token, $str) && isset($contact[$name . '.' . $token])) {
1078 self::token_replace($name, $token, $contact[$name . '.' . $token], $str, $escapeSmarty);
1079 }
1080 elseif (!$returnEmptyToken) {
1081 //replacing empty token
1082 self::token_replace($name, $token, "", $str, $escapeSmarty);
1083 }
1084 }
1085 }
1086 return $str;
1087 }
1088
1089 /**
fe482240 1090 * Get array of string tokens.
6a488035 1091 *
5a4f6742 1092 * @param string $string
77855840 1093 * The input string to parse for tokens.
6a488035 1094 *
a6c01b45
CW
1095 * @return array
1096 * array of tokens mentioned in field
6a488035 1097 */
00be9182 1098 public static function getTokens($string) {
be2fb01f
CW
1099 $matches = [];
1100 $tokens = [];
6a488035
TO
1101 preg_match_all('/(?<!\{|\\\\)\{(\w+\.\w+)\}(?!\})/',
1102 $string,
1103 $matches,
1104 PREG_PATTERN_ORDER
1105 );
1106
1107 if ($matches[1]) {
1108 foreach ($matches[1] as $token) {
3017ca78 1109 [$type, $name] = preg_split('/\./', $token, 2);
6a488035
TO
1110 if ($name && $type) {
1111 if (!isset($tokens[$type])) {
be2fb01f 1112 $tokens[$type] = [];
6a488035
TO
1113 }
1114 $tokens[$type][] = $name;
1115 }
1116 }
1117 }
1118 return $tokens;
1119 }
1120
bdd49e38
EM
1121 /**
1122 * Function to determine which values to retrieve to insert into tokens. The heavy resemblance between this function
1123 * and getTokens appears to be historical rather than intentional and should be reviewed
1124 * @param $string
a6c01b45
CW
1125 * @return array
1126 * fields to pass in as return properties when populating token
bdd49e38
EM
1127 */
1128 public static function getReturnProperties(&$string) {
be2fb01f
CW
1129 $returnProperties = [];
1130 $matches = [];
e7292422 1131 preg_match_all('/(?<!\{|\\\\)\{(\w+\.\w+)\}(?!\})/',
353ffa53
TO
1132 $string,
1133 $matches,
1134 PREG_PATTERN_ORDER
1135 );
e7292422
TO
1136 if ($matches[1]) {
1137 foreach ($matches[1] as $token) {
3017ca78 1138 [$type, $name] = preg_split('/\./', $token, 2);
e7292422
TO
1139 if ($name) {
1140 $returnProperties["{$name}"] = 1;
bdd49e38
EM
1141 }
1142 }
e7292422 1143 }
bdd49e38 1144
e7292422 1145 return $returnProperties;
bdd49e38
EM
1146 }
1147
6a488035 1148 /**
100fef9d 1149 * Gives required details of contacts in an indexed array format so we
6a488035
TO
1150 * can iterate in a nice loop and do token evaluation
1151 *
36f5faa3 1152 * @param array $contactIDs
77855840
TO
1153 * @param array $returnProperties
1154 * Of required properties.
e7483cbe 1155 * @param bool $skipOnHold Don't return on_hold contact info also.
77855840 1156 * Don't return on_hold contact info also.
e7483cbe 1157 * @param bool $skipDeceased Don't return deceased contact info.
77855840
TO
1158 * Don't return deceased contact info.
1159 * @param array $extraParams
1160 * Extra params.
1161 * @param array $tokens
1162 * The list of tokens we've extracted from the content.
4d385723 1163 * @param string|null $className
1164 * @param int|null $jobID
77855840 1165 * The mailing list jobID - this is a legacy param.
6a488035 1166 *
4d385723 1167 * @return array - e.g [[1 => ['first_name' => 'bob'...], 34 => ['first_name' => 'fred'...]]]
6a488035 1168 */
e7483cbe 1169 public static function getTokenDetails(
a3e55d9c 1170 $contactIDs,
6a488035 1171 $returnProperties = NULL,
e7292422
TO
1172 $skipOnHold = TRUE,
1173 $skipDeceased = TRUE,
1174 $extraParams = NULL,
be2fb01f 1175 $tokens = [],
e7292422
TO
1176 $className = NULL,
1177 $jobID = NULL
6a488035 1178 ) {
0b3cb19d 1179
be2fb01f 1180 $params = [];
36f5faa3 1181 foreach ($contactIDs as $contactID) {
be2fb01f 1182 $params[] = [
6a488035 1183 CRM_Core_Form::CB_PREFIX . $contactID,
353ffa53
TO
1184 '=',
1185 1,
1186 0,
1187 0,
be2fb01f 1188 ];
6a488035
TO
1189 }
1190
1191 // fix for CRM-2613
1192 if ($skipDeceased) {
be2fb01f 1193 $params[] = ['is_deceased', '=', 0, 0, 0];
6a488035
TO
1194 }
1195
1196 //fix for CRM-3798
1197 if ($skipOnHold) {
be2fb01f 1198 $params[] = ['on_hold', '=', 0, 0, 0];
6a488035
TO
1199 }
1200
1201 if ($extraParams) {
1202 $params = array_merge($params, $extraParams);
1203 }
1204
1205 // if return properties are not passed then get all return properties
1206 if (empty($returnProperties)) {
1207 $fields = array_merge(array_keys(CRM_Contact_BAO_Contact::exportableFields()),
be2fb01f 1208 ['display_name', 'checksum', 'contact_id']
6a488035 1209 );
36f5faa3 1210 foreach ($fields as $val) {
b8a4ead8 1211 // The unavailable fields are not available as tokens, do not have a one-2-one relationship
1212 // with contacts and are expensive to resolve.
1213 // @todo see CRM-17253 - there are some other fields (e.g note) that should be excluded
1214 // and upstream calls to this should populate return properties.
be2fb01f 1215 $unavailableFields = ['group', 'tag'];
b8a4ead8 1216 if (!in_array($val, $unavailableFields)) {
1217 $returnProperties[$val] = 1;
1218 }
6a488035
TO
1219 }
1220 }
1221
be2fb01f 1222 $custom = [];
6a488035
TO
1223 foreach ($returnProperties as $name => $dontCare) {
1224 $cfID = CRM_Core_BAO_CustomField::getKeyID($name);
1225 if ($cfID) {
1226 $custom[] = $cfID;
1227 }
1228 }
1229
4d385723 1230 [$contactDetails] = CRM_Contact_BAO_Query::apiQuery($params, $returnProperties, NULL, NULL, 0, count($contactIDs), TRUE, FALSE, TRUE, CRM_Contact_BAO_Query::MODE_CONTACTS, NULL, TRUE);
6a488035 1231
36f5faa3 1232 foreach ($contactIDs as $contactID) {
6a488035 1233 if (array_key_exists($contactID, $contactDetails)) {
5973d2e1 1234 if (!empty($contactDetails[$contactID]['preferred_communication_method'])
6a488035 1235 ) {
be2fb01f 1236 $communicationPreferences = [];
160e7328 1237 foreach ((array) $contactDetails[$contactID]['preferred_communication_method'] as $val) {
6a488035 1238 if ($val) {
5973d2e1 1239 $communicationPreferences[$val] = CRM_Core_PseudoConstant::getLabel('CRM_Contact_DAO_Contact', 'preferred_communication_method', $val);
6a488035
TO
1240 }
1241 }
5973d2e1 1242 $contactDetails[$contactID]['preferred_communication_method'] = implode(', ', $communicationPreferences);
6a488035
TO
1243 }
1244
1245 foreach ($custom as $cfID) {
1246 if (isset($contactDetails[$contactID]["custom_{$cfID}"])) {
8cee0c70 1247 $contactDetails[$contactID]["custom_{$cfID}"] = CRM_Core_BAO_CustomField::displayValue($contactDetails[$contactID]["custom_{$cfID}"], $cfID);
6a488035
TO
1248 }
1249 }
1250
50bfb460 1251 // special case for greeting replacement
be2fb01f 1252 foreach ([
6714d8d2
SL
1253 'email_greeting',
1254 'postal_greeting',
1255 'addressee',
1256 ] as $val) {
a7488080 1257 if (!empty($contactDetails[$contactID][$val])) {
6a488035
TO
1258 $contactDetails[$contactID][$val] = $contactDetails[$contactID]["{$val}_display"];
1259 }
1260 }
1261 }
1262 }
1263
590111ef 1264 // $contactDetails = &$details[0] = is an array of [ contactID => contactDetails ]
6a488035 1265 // also call a hook and get token details
590111ef 1266 CRM_Utils_Hook::tokenValues($contactDetails,
6a488035
TO
1267 $contactIDs,
1268 $jobID,
1269 $tokens,
1270 $className
1271 );
4d385723 1272 return [$contactDetails];
6a488035
TO
1273 }
1274
d20c4dad
EM
1275 /**
1276 * Call hooks on tokens for anonymous users - contact id is set to 0 - this allows non-contact
1277 * specific tokens to be rendered
1278 *
77855840
TO
1279 * @param array $contactIDs
1280 * This should always be array(0) or its not anonymous - left to keep signature same.
16b10e64 1281 * as main fn
d20c4dad 1282 * @param string $returnProperties
77855840
TO
1283 * @param bool $skipOnHold
1284 * @param bool $skipDeceased
d20c4dad
EM
1285 * @param string $extraParams
1286 * @param array $tokens
77855840
TO
1287 * @param string $className
1288 * Sent as context to the hook.
d20c4dad 1289 * @param string $jobID
a6c01b45
CW
1290 * @return array
1291 * contactDetails with hooks swapped out
d20c4dad 1292 */
590111ef 1293 public static function getAnonymousTokenDetails($contactIDs = [0],
353ffa53
TO
1294 $returnProperties = NULL,
1295 $skipOnHold = TRUE,
1296 $skipDeceased = TRUE,
1297 $extraParams = NULL,
be2fb01f 1298 $tokens = [],
353ffa53
TO
1299 $className = NULL,
1300 $jobID = NULL) {
be2fb01f 1301 $details = [0 => []];
e7292422
TO
1302 // also call a hook and get token details
1303 CRM_Utils_Hook::tokenValues($details[0],
d20c4dad
EM
1304 $contactIDs,
1305 $jobID,
1306 $tokens,
1307 $className
1308 );
1309 return $details;
1310 }
e39893f5 1311
2d3e3c7b 1312 /**
fe482240 1313 * Get Membership Token Details.
77855840
TO
1314 * @param array $membershipIDs
1315 * Array of membership IDS.
2d3e3c7b 1316 */
00be9182 1317 public static function getMembershipTokenDetails($membershipIDs) {
be2fb01f
CW
1318 $memberships = civicrm_api3('membership', 'get', [
1319 'options' => ['limit' => 0],
1320 'membership_id' => ['IN' => (array) $membershipIDs],
1321 ]);
2d3e3c7b 1322 return $memberships['values'];
1323 }
353ffa53 1324
6a488035 1325 /**
50bfb460 1326 * Replace existing greeting tokens in message/subject.
54957108 1327 *
206f0198
CB
1328 * This function operates by reference, modifying the first parameter. Other
1329 * methods for token replacement in this class return the modified string.
1330 * This leads to inconsistency in how these methods must be applied.
1331 *
1332 * @TODO Remove that inconsistency in usage.
1333 *
1334 * ::replaceContactTokens() may need to be called after this method, to
1335 * replace tokens supplied from this method.
1336 *
54957108 1337 * @param string $tokenString
1338 * @param array $contactDetails
1339 * @param int $contactId
1340 * @param string $className
1341 * @param bool $escapeSmarty
6a488035 1342 */
00be9182 1343 public static function replaceGreetingTokens(&$tokenString, $contactDetails = NULL, $contactId = NULL, $className = NULL, $escapeSmarty = FALSE) {
6a488035
TO
1344
1345 if (!$contactDetails && !$contactId) {
1346 return;
1347 }
1348
1349 // check if there are any tokens
1350 $greetingTokens = self::getTokens($tokenString);
1351
1352 if (!empty($greetingTokens)) {
1353 // first use the existing contact object for token replacement
1354 if (!empty($contactDetails)) {
73d64eb6 1355 $tokenString = CRM_Utils_Token::replaceContactTokens($tokenString, $contactDetails, TRUE, $greetingTokens, TRUE, $escapeSmarty);
6a488035
TO
1356 }
1357
578c7a3a 1358 self::removeNullContactTokens($tokenString, $contactDetails, $greetingTokens);
6a488035
TO
1359 // check if there are any unevaluated tokens
1360 $greetingTokens = self::getTokens($tokenString);
1361
e7483cbe
J
1362 // $greetingTokens not empty, means there are few tokens which are not
1363 // evaluated, like custom data etc
6a488035
TO
1364 // so retrieve it from database
1365 if (!empty($greetingTokens) && array_key_exists('contact', $greetingTokens)) {
1366 $greetingsReturnProperties = array_flip(CRM_Utils_Array::value('contact', $greetingTokens));
1367 $greetingsReturnProperties = array_fill_keys(array_keys($greetingsReturnProperties), 1);
be2fb01f 1368 $contactParams = ['contact_id' => $contactId];
6a488035
TO
1369
1370 $greetingDetails = self::getTokenDetails($contactParams,
1371 $greetingsReturnProperties,
1372 FALSE, FALSE, NULL,
1373 $greetingTokens,
1374 $className
1375 );
1376
1377 // again replace tokens
1378 $tokenString = CRM_Utils_Token::replaceContactTokens($tokenString,
1379 $greetingDetails,
1380 TRUE,
73d64eb6 1381 $greetingTokens,
f551f7d3 1382 TRUE,
73d64eb6 1383 $escapeSmarty
6a488035
TO
1384 );
1385 }
d75f2f47 1386
ac21a108 1387 // check if there are still any unevaluated tokens
562cf4d7 1388 $remainingTokens = self::getTokens($tokenString);
ac21a108 1389
f551f7d3
NH
1390 // $greetingTokens not empty, there are customized or hook tokens to replace
1391 if (!empty($remainingTokens)) {
ac21a108 1392 // Fill the return properties array
f551f7d3 1393 $greetingTokens = $remainingTokens;
ac21a108 1394 reset($greetingTokens);
be2fb01f 1395 $greetingsReturnProperties = [];
78f2de98
SL
1396 foreach ($greetingTokens as $value) {
1397 $props = array_flip($value);
ac21a108
JM
1398 $props = array_fill_keys(array_keys($props), 1);
1399 $greetingsReturnProperties = $greetingsReturnProperties + $props;
1400 }
be2fb01f 1401 $contactParams = ['contact_id' => $contactId];
ac21a108
JM
1402 $greetingDetails = self::getTokenDetails($contactParams,
1403 $greetingsReturnProperties,
1404 FALSE, FALSE, NULL,
1405 $greetingTokens,
1406 $className
1407 );
1408 // Prepare variables for calling replaceHookTokens
1409 $categories = array_keys($greetingTokens);
3017ca78 1410 [$contact] = $greetingDetails;
ac21a108
JM
1411 // Replace tokens defined in Hooks.
1412 $tokenString = CRM_Utils_Token::replaceHookTokens($tokenString, $contact[$contactId], $categories);
1413 }
6a488035
TO
1414 }
1415 }
1416
663cc0b4
J
1417 /**
1418 * At this point, $contactDetails has loaded the contact from the DAO. Any
1419 * (non-custom) missing fields are null. By removing them, we can avoid
1420 * expensive calls to CRM_Contact_BAO_Query.
1421 *
1422 * @param string $tokenString
1423 * @param array $contactDetails
6714d8d2 1424 * @param array $greetingTokens
663cc0b4
J
1425 */
1426 private static function removeNullContactTokens(&$tokenString, $contactDetails, &$greetingTokens) {
dcdff6e6 1427
a2a7ed37
JK
1428 // Only applies to contact tokens
1429 if (!array_key_exists('contact', $greetingTokens)) {
1430 return;
1431 }
dcdff6e6 1432
663cc0b4
J
1433 $greetingTokensOriginal = $greetingTokens;
1434 $contactFieldList = CRM_Contact_DAO_Contact::fields();
578c7a3a
J
1435 // Sometimes contactDetails are in a multidemensional array, sometimes a
1436 // single-dimension array.
1437 if (array_key_exists(0, $contactDetails) && is_array($contactDetails[0])) {
1438 $contactDetails = current($contactDetails[0]);
1439 }
663cc0b4
J
1440 $nullFields = array_keys(array_diff_key($contactFieldList, $contactDetails));
1441
1442 // Handle legacy tokens
1443 foreach (self::legacyContactTokens() as $oldToken => $newToken) {
1444 if (CRM_Utils_Array::key($newToken, $nullFields)) {
1445 $nullFields[] = $oldToken;
1446 }
1447 }
1448
1449 // Remove null contact fields from $greetingTokens
1450 $greetingTokens['contact'] = array_diff($greetingTokens['contact'], $nullFields);
1451
1452 // Also remove them from $tokenString
1453 $removedTokens = array_diff($greetingTokensOriginal['contact'], $greetingTokens['contact']);
1454 // Handle legacy tokens again, sigh
1455 if (!empty($removedTokens)) {
1456 foreach ($removedTokens as $token) {
1457 if (CRM_Utils_Array::value($token, self::legacyContactTokens()) !== NULL) {
1458 $removedTokens[] = CRM_Utils_Array::value($token, self::legacyContactTokens());
1459 }
1460 }
1461 foreach ($removedTokens as $token) {
1462 $tokenString = str_replace("{contact.$token}", '', $tokenString);
1463 }
1464 }
1465 }
1466
5bc392e6
EM
1467 /**
1468 * @param $tokens
1469 *
1470 * @return array
1471 */
00be9182 1472 public static function flattenTokens(&$tokens) {
be2fb01f 1473 $flattenTokens = [];
6a488035 1474
be2fb01f 1475 foreach ([
6714d8d2
SL
1476 'html',
1477 'text',
1478 'subject',
1479 ] as $prop) {
6a488035
TO
1480 if (!isset($tokens[$prop])) {
1481 continue;
1482 }
1483 foreach ($tokens[$prop] as $type => $names) {
1484 if (!isset($flattenTokens[$type])) {
be2fb01f 1485 $flattenTokens[$type] = [];
6a488035
TO
1486 }
1487 foreach ($names as $name) {
1488 $flattenTokens[$type][$name] = 1;
1489 }
1490 }
1491 }
1492
1493 return $flattenTokens;
1494 }
1495
1496 /**
1497 * Replace all user tokens in $str
1498 *
77855840
TO
1499 * @param string $str
1500 * The string with tokens to be replaced.
e39893f5
EM
1501 *
1502 * @param null $knownTokens
1503 * @param bool $escapeSmarty
6a488035 1504 *
a6c01b45
CW
1505 * @return string
1506 * The processed string
6a488035
TO
1507 */
1508 public static function &replaceUserTokens($str, $knownTokens = NULL, $escapeSmarty = FALSE) {
1509 $key = 'user';
1510 if (!$knownTokens ||
1511 !isset($knownTokens[$key])
1512 ) {
1513 return $str;
1514 }
1515
8bab0eb0
DL
1516 $str = preg_replace_callback(
1517 self::tokenRegex($key),
353ffa53
TO
1518 function ($matches) use ($escapeSmarty) {
1519 return CRM_Utils_Token::getUserTokenReplacement($matches[1], $escapeSmarty);
8bab0eb0
DL
1520 },
1521 $str
6a488035
TO
1522 );
1523 return $str;
1524 }
1525
e39893f5
EM
1526 /**
1527 * @param $token
1528 * @param bool $escapeSmarty
1529 *
1530 * @return string
1531 */
6a488035
TO
1532 public static function getUserTokenReplacement($token, $escapeSmarty = FALSE) {
1533 $value = '';
1534
3017ca78 1535 [$objectName, $objectValue] = explode('-', $token, 2);
6a488035
TO
1536
1537 switch ($objectName) {
1538 case 'permission':
1539 $value = CRM_Core_Permission::permissionEmails($objectValue);
1540 break;
1541
1542 case 'role':
1543 $value = CRM_Core_Permission::roleEmails($objectValue);
1544 break;
1545 }
1546
1547 if ($escapeSmarty) {
1548 $value = self::tokenEscapeSmarty($value);
1549 }
1550
1551 return $value;
1552 }
1553
6a488035
TO
1554 protected static function _buildContributionTokens() {
1555 $key = 'contribution';
1556 if (self::$_tokens[$key] == NULL) {
1557 self::$_tokens[$key] = array_keys(array_merge(CRM_Contribute_BAO_Contribution::exportableFields('All'),
be2fb01f 1558 ['campaign', 'financial_type'],
18c017c8 1559 self::getCustomFieldTokens('Contribution')
353ffa53 1560 ));
6a488035
TO
1561 }
1562 }
1563
2d3e3c7b 1564 /**
fe482240 1565 * Store membership tokens on the static _tokens array.
2d3e3c7b 1566 */
1567 protected static function _buildMembershipTokens() {
1568 $key = 'membership';
21d6154c 1569 if (!isset(self::$_tokens[$key]) || self::$_tokens[$key] == NULL) {
be2fb01f 1570 $membershipTokens = [];
2d3e3c7b 1571 $tokens = CRM_Core_SelectValues::membershipTokens();
1572 foreach ($tokens as $token => $dontCare) {
1573 $membershipTokens[] = substr($token, (strpos($token, '.') + 1), -1);
1574 }
1575 self::$_tokens[$key] = $membershipTokens;
1576 }
1577 }
1578
1579 /**
fe482240 1580 * Replace tokens for an entity.
2d3e3c7b 1581 * @param string $entity
77855840
TO
1582 * @param array $entityArray
1583 * (e.g. in format from api).
1584 * @param string $str
1585 * String to replace in.
1586 * @param array $knownTokens
1587 * Array of tokens present.
1588 * @param bool $escapeSmarty
a6c01b45
CW
1589 * @return string
1590 * string with replacements made
2d3e3c7b 1591 */
be2fb01f 1592 public static function replaceEntityTokens($entity, $entityArray, $str, $knownTokens = [], $escapeSmarty = FALSE) {
8cc574cf 1593 if (!$knownTokens || empty($knownTokens[$entity])) {
2d3e3c7b 1594 return $str;
1595 }
1596
07945b3c 1597 $fn = 'get' . ucfirst($entity) . 'TokenReplacement';
be2fb01f 1598 $fn = is_callable(['CRM_Utils_Token', $fn]) ? $fn : 'getApiTokenReplacement';
50bfb460 1599 // since we already know the tokens lets just use them & do str_replace which is faster & simpler than preg_replace
2d3e3c7b 1600 foreach ($knownTokens[$entity] as $token) {
07945b3c
CW
1601 $replacement = self::$fn($entity, $token, $entityArray);
1602 if ($escapeSmarty) {
1603 $replacement = self::tokenEscapeSmarty($replacement);
1604 }
1605 $str = str_replace('{' . $entity . '.' . $token . '}', $replacement, $str);
2d3e3c7b 1606 }
07945b3c
CW
1607 return preg_replace('/\\\\|\{(\s*)?\}/', ' ', $str);
1608 }
1609
1610 /**
1611 * @param int $caseId
f274450a 1612 * @param string $str
07945b3c
CW
1613 * @param array $knownTokens
1614 * @param bool $escapeSmarty
1615 * @return string
1616 * @throws \CiviCRM_API3_Exception
1617 */
be2fb01f 1618 public static function replaceCaseTokens($caseId, $str, $knownTokens = [], $escapeSmarty = FALSE) {
07945b3c
CW
1619 if (!$knownTokens || empty($knownTokens['case'])) {
1620 return $str;
1621 }
be2fb01f 1622 $case = civicrm_api3('case', 'getsingle', ['id' => $caseId]);
07945b3c
CW
1623 return self::replaceEntityTokens('case', $case, $str, $knownTokens, $escapeSmarty);
1624 }
1625
1626 /**
1627 * Generic function for formatting token replacement for an api field
1628 *
1629 * @param string $entity
1630 * @param string $token
1631 * @param array $entityArray
1632 * @return string
1633 * @throws \CiviCRM_API3_Exception
1634 */
1635 public static function getApiTokenReplacement($entity, $token, $entityArray) {
1636 if (!isset($entityArray[$token])) {
1637 return '';
1638 }
be2fb01f 1639 $field = civicrm_api3($entity, 'getfield', ['action' => 'get', 'name' => $token, 'get_options' => 'get']);
07945b3c 1640 $field = $field['values'];
9c1bc317 1641 $fieldType = $field['type'] ?? NULL;
6f9518ec
CW
1642 // Boolean fields
1643 if ($fieldType == CRM_Utils_Type::T_BOOLEAN && empty($field['options'])) {
be2fb01f 1644 $field['options'] = [ts('No'), ts('Yes')];
6f9518ec 1645 }
07945b3c
CW
1646 // Match pseudoconstants
1647 if (!empty($field['options'])) {
be2fb01f 1648 $ret = [];
07945b3c
CW
1649 foreach ((array) $entityArray[$token] as $val) {
1650 $ret[] = $field['options'][$val];
1651 }
1652 return implode(', ', $ret);
1653 }
6f9518ec
CW
1654 // Format date fields
1655 elseif ($entityArray[$token] && $fieldType == CRM_Utils_Type::T_DATE) {
07945b3c
CW
1656 return CRM_Utils_Date::customFormat($entityArray[$token]);
1657 }
6f9518ec 1658 return implode(', ', (array) $entityArray[$token]);
2d3e3c7b 1659 }
1660
383c047b 1661 /**
fe482240 1662 * Replace Contribution tokens in html.
e39893f5 1663 *
115dba92
EM
1664 * @param string $str
1665 * @param array $contribution
e39893f5 1666 * @param bool|string $html
383c047b 1667 * @param string $knownTokens
e39893f5
EM
1668 * @param bool|string $escapeSmarty
1669 *
72b3a70c 1670 * @return mixed
383c047b
DG
1671 */
1672 public static function replaceContributionTokens($str, &$contribution, $html = FALSE, $knownTokens = NULL, $escapeSmarty = FALSE) {
1673 $key = 'contribution';
b99f3e96 1674 if (!$knownTokens || empty($knownTokens[$key])) {
6714d8d2
SL
1675 //early return
1676 return $str;
383c047b 1677 }
6a488035
TO
1678 self::_buildContributionTokens();
1679
1680 // here we intersect with the list of pre-configured valid tokens
1681 // so that we remove anything we do not recognize
1682 // I hope to move this step out of here soon and
1683 // then we will just iterate on a list of tokens that are passed to us
6a488035 1684
8bab0eb0
DL
1685 $str = preg_replace_callback(
1686 self::tokenRegex($key),
353ffa53
TO
1687 function ($matches) use (&$contribution, $html, $escapeSmarty) {
1688 return CRM_Utils_Token::getContributionTokenReplacement($matches[1], $contribution, $html, $escapeSmarty);
8bab0eb0 1689 },
6a488035
TO
1690 $str
1691 );
1692
1693 $str = preg_replace('/\\\\|\{(\s*)?\}/', ' ', $str);
1694 return $str;
1695 }
1696
383c047b
DG
1697 /**
1698 * We have a situation where we are rendering more than one token in each field because we are combining
1699 * tokens from more than one contribution when pdf thank you letters are grouped (CRM-14367)
1700 *
1701 * The replaceContributionToken doesn't handle receive_date correctly in this scenario because of the formatting
1702 * it applies (other tokens are OK including date fields)
1703 *
1704 * So we sort this out & then call the main function. Note that we are not escaping smarty on this fields like the main function
1705 * does - but the fields is already being formatted through a date function
1706 *
1707 * @param string $separator
1708 * @param string $str
2f5a20da 1709 * @param array $contributions
1710 * @param array $knownTokens
e39893f5 1711 *
6f9518ec 1712 * @return string
383c047b 1713 */
2f5a20da 1714 public static function replaceMultipleContributionTokens(string $separator, string $str, array $contributions, array $knownTokens): string {
1715 foreach ($knownTokens['contribution'] ?? [] as $token) {
1716 $resolvedTokens = [];
1717 foreach ($contributions as $contribution) {
1718 $resolvedTokens[] = self::replaceContributionTokens('{contribution.' . $token . '}', $contribution, FALSE, $knownTokens);
383c047b 1719 }
2f5a20da 1720 $str = self::token_replace('contribution', $token, implode($separator, $resolvedTokens), $str);
383c047b 1721 }
2f5a20da 1722 return $str;
383c047b
DG
1723 }
1724
2d3e3c7b 1725 /**
1726 * Get replacement strings for any membership tokens (only a small number of tokens are implemnted in the first instance
1727 * - this is used by the pdfLetter task from membership search
6f9518ec
CW
1728 * @param string $entity
1729 * should always be "membership"
2d3e3c7b 1730 * @param string $token
6f9518ec 1731 * field name
77855840
TO
1732 * @param array $membership
1733 * An api result array for a single membership.
6f9518ec 1734 * @return string token replacement
2d3e3c7b 1735 */
07945b3c 1736 public static function getMembershipTokenReplacement($entity, $token, $membership) {
2d3e3c7b 1737 self::_buildMembershipTokens();
e7292422
TO
1738 switch ($token) {
1739 case 'type':
1740 $value = $membership['membership_name'];
1741 break;
1742
1743 case 'status':
1744 $statuses = CRM_Member_BAO_Membership::buildOptions('status_id');
1745 $value = $statuses[$membership['status_id']];
1746 break;
1747
1748 case 'fee':
92e4c2a5 1749 try {
be2fb01f 1750 $value = civicrm_api3('membership_type', 'getvalue', [
6714d8d2
SL
1751 'id' => $membership['membership_type_id'],
1752 'return' => 'minimum_fee',
1753 ]);
daec3f08 1754 $value = CRM_Utils_Money::format($value, NULL, NULL, TRUE);
e7292422
TO
1755 }
1756 catch (CiviCRM_API3_Exception $e) {
1757 // we can anticipate we will get an error if the minimum fee is set to 'NULL' because of the way the
1758 // api handles NULL (4.4)
1759 $value = 0;
1760 }
1761 break;
1762
1763 default:
1764 if (in_array($token, self::$_tokens[$entity])) {
1765 $value = $membership[$token];
7ab8b45b 1766 if (CRM_Utils_String::endsWith($token, '_date')) {
1767 $value = CRM_Utils_Date::customFormat($value);
1768 }
e7292422
TO
1769 }
1770 else {
50bfb460 1771 // ie unchanged
e7292422
TO
1772 $value = "{$entity}.{$token}";
1773 }
1774 break;
2d3e3c7b 1775 }
1776
2d3e3c7b 1777 return $value;
1778 }
1779
e39893f5
EM
1780 /**
1781 * @param $token
1782 * @param $contribution
1783 * @param bool $html
1784 * @param bool $escapeSmarty
1785 *
1786 * @return mixed|string
3017ca78 1787 * @throws \CRM_Core_Exception
e39893f5 1788 */
6a488035
TO
1789 public static function getContributionTokenReplacement($token, &$contribution, $html = FALSE, $escapeSmarty = FALSE) {
1790 self::_buildContributionTokens();
1791
1792 switch ($token) {
1793 case 'total_amount':
1794 case 'net_amount':
1795 case 'fee_amount':
1796 case 'non_deductible_amount':
2aa64508
JG
1797 // FIXME: Is this ever a multi-dimensional array? Why use retrieveValueRecursive()?
1798 $amount = CRM_Utils_Array::retrieveValueRecursive($contribution, $token);
1799 $currency = CRM_Utils_Array::retrieveValueRecursive($contribution, 'currency');
1800 $value = CRM_Utils_Money::format($amount, $currency);
6a488035
TO
1801 break;
1802
1803 case 'receive_date':
9a32c3e5 1804 case 'receipt_date':
6a488035 1805 $value = CRM_Utils_Array::retrieveValueRecursive($contribution, $token);
9a32c3e5
JF
1806 $config = CRM_Core_Config::singleton();
1807 $value = CRM_Utils_Date::customFormat($value, $config->dateformatDatetime);
6a488035
TO
1808 break;
1809
1810 default:
1811 if (!in_array($token, self::$_tokens['contribution'])) {
1812 $value = "{contribution.$token}";
1813 }
1814 else {
1815 $value = CRM_Utils_Array::retrieveValueRecursive($contribution, $token);
1816 }
1817 break;
1818 }
1819
6a488035
TO
1820 if ($escapeSmarty) {
1821 $value = self::tokenEscapeSmarty($value);
1822 }
1823 return $value;
1824 }
1825
091133bb 1826 /**
a6c01b45 1827 * @return array
6f9518ec 1828 * [legacy_token => new_token]
091133bb 1829 */
00be9182 1830 public static function legacyContactTokens() {
be2fb01f 1831 return [
091133bb
CW
1832 'individual_prefix' => 'prefix_id',
1833 'individual_suffix' => 'suffix_id',
1834 'gender' => 'gender_id',
aa62b355 1835 'communication_style' => 'communication_style_id',
be2fb01f 1836 ];
091133bb
CW
1837 }
1838
18c017c8 1839 /**
1840 * Get all custom field tokens of $entity
1841 *
1842 * @param string $entity
1843 * @param bool $usedForTokenWidget
1844 *
6714d8d2 1845 * @return array
18c017c8 1846 * return custom field tokens in array('custom_N' => 'label') format
1847 */
1848 public static function getCustomFieldTokens($entity, $usedForTokenWidget = FALSE) {
be2fb01f 1849 $customTokens = [];
18c017c8 1850 $tokenName = $usedForTokenWidget ? "{contribution.custom_%d}" : "custom_%d";
1851 foreach (CRM_Core_BAO_CustomField::getFields($entity) as $id => $info) {
1852 $customTokens[sprintf($tokenName, $id)] = $info['label'];
1853 }
1854
1855 return $customTokens;
1856 }
1857
ac0a3db5
CW
1858 /**
1859 * Formats a token list for the select2 widget
9950a1c9 1860 *
ac0a3db5
CW
1861 * @param $tokens
1862 * @return array
1863 */
00be9182 1864 public static function formatTokensForDisplay($tokens) {
be2fb01f 1865 $sorted = $output = [];
ac0a3db5
CW
1866
1867 // Sort in ascending order by ignoring word case
1868 natcasesort($tokens);
1869
1870 // Attempt to place tokens into optgroups
50bfb460 1871 // @todo These groupings could be better and less hackish. Getting them pre-grouped from upstream would be nice.
ac0a3db5
CW
1872 foreach ($tokens as $k => $v) {
1873 // Check to see if this token is already in a group e.g. for custom fields
1874 $split = explode(' :: ', $v);
1875 if (!empty($split[1])) {
be2fb01f 1876 $sorted[$split[1]][] = ['id' => $k, 'text' => $split[0]];
ac0a3db5
CW
1877 }
1878 // Group by entity
1879 else {
1880 $split = explode('.', trim($k, '{}'));
cc666210 1881 if (isset($split[1])) {
f274450a 1882 $entity = array_key_exists($split[1], CRM_Core_DAO_Address::export()) ? 'Address' : ucwords(str_replace('_', ' ', $split[0]));
cc666210
CW
1883 }
1884 else {
1885 $entity = 'Contact';
1886 }
be2fb01f 1887 $sorted[ts($entity)][] = ['id' => $k, 'text' => $v];
ac0a3db5
CW
1888 }
1889 }
1890
1891 ksort($sorted);
1892 foreach ($sorted as $k => $v) {
be2fb01f 1893 $output[] = ['text' => $k, 'children' => $v];
ac0a3db5
CW
1894 }
1895
1896 return $output;
1897 }
96025800 1898
6a488035 1899}