Merge pull request #17116 from lcdservices/dev-core-1721
[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
TO
171 */
172 public static function &token_replace($type, $var, $value, &$str, $escapeSmarty = FALSE) {
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 */
21bb6c7b
DL
227 public static function &replaceDomainTokens(
228 $str,
229 &$domain,
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),
353ffa53
TO
243 function ($matches) use (&$domain, $html, $escapeSmarty) {
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
EM
251 /**
252 * @param $token
253 * @param $domain
254 * @param bool $html
255 * @param bool $escapeSmarty
256 *
257 * @return mixed|null|string
258 */
6a488035
TO
259 public static function getDomainTokenReplacement($token, &$domain, $html = FALSE, $escapeSmarty = FALSE) {
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
264 $loc = &$domain->getLocationValues();
265
266 if (!in_array($token, self::$_tokens['domain'])) {
267 $value = "{domain.$token}";
268 }
269 elseif ($token == 'address') {
be2fb01f 270 static $addressCache = [];
6a488035
TO
271
272 $cache_key = $html ? 'address-html' : 'address-text';
273 if (array_key_exists($cache_key, $addressCache)) {
274 return $addressCache[$cache_key];
275 }
276
277 $value = NULL;
50bfb460 278 // Construct the address token
6a488035 279
a7488080 280 if (!empty($loc[$token])) {
6a488035
TO
281 if ($html) {
282 $value = $loc[$token][1]['display'];
283 $value = str_replace("\n", '<br />', $value);
284 }
285 else {
286 $value = $loc[$token][1]['display_text'];
287 }
288 $addressCache[$cache_key] = $value;
289 }
290 }
e3470b79 291 elseif ($token == 'name' || $token == 'id' || $token == 'description') {
6a488035
TO
292 $value = $domain->$token;
293 }
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 */
21bb6c7b
DL
647 public static function &replaceContactTokens(
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) {
1109 list($type, $name) = preg_split('/\./', $token, 2);
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) {
1138 list($type, $name) = preg_split('/\./', $token, 2);
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.
e39893f5 1163 * @param null $className
77855840
TO
1164 * @param int $jobID
1165 * The mailing list jobID - this is a legacy param.
6a488035
TO
1166 *
1167 * @return array
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
0606198b 1230 $details = CRM_Contact_BAO_Query::apiQuery($params, $returnProperties, NULL, NULL, 0, count($contactIDs), TRUE, FALSE, TRUE, CRM_Contact_BAO_Query::MODE_CONTACTS, NULL, TRUE);
6a488035
TO
1231 $contactDetails = &$details[0];
1232
36f5faa3 1233 foreach ($contactIDs as $contactID) {
6a488035 1234 if (array_key_exists($contactID, $contactDetails)) {
5973d2e1 1235 if (!empty($contactDetails[$contactID]['preferred_communication_method'])
6a488035 1236 ) {
be2fb01f 1237 $communicationPreferences = [];
36f5faa3 1238 foreach ($contactDetails[$contactID]['preferred_communication_method'] as $val) {
6a488035 1239 if ($val) {
5973d2e1 1240 $communicationPreferences[$val] = CRM_Core_PseudoConstant::getLabel('CRM_Contact_DAO_Contact', 'preferred_communication_method', $val);
6a488035
TO
1241 }
1242 }
5973d2e1 1243 $contactDetails[$contactID]['preferred_communication_method'] = implode(', ', $communicationPreferences);
6a488035
TO
1244 }
1245
1246 foreach ($custom as $cfID) {
1247 if (isset($contactDetails[$contactID]["custom_{$cfID}"])) {
8cee0c70 1248 $contactDetails[$contactID]["custom_{$cfID}"] = CRM_Core_BAO_CustomField::displayValue($contactDetails[$contactID]["custom_{$cfID}"], $cfID);
6a488035
TO
1249 }
1250 }
1251
50bfb460 1252 // special case for greeting replacement
be2fb01f 1253 foreach ([
6714d8d2
SL
1254 'email_greeting',
1255 'postal_greeting',
1256 'addressee',
1257 ] as $val) {
a7488080 1258 if (!empty($contactDetails[$contactID][$val])) {
6a488035
TO
1259 $contactDetails[$contactID][$val] = $contactDetails[$contactID]["{$val}_display"];
1260 }
1261 }
1262 }
1263 }
1264
590111ef 1265 // $contactDetails = &$details[0] = is an array of [ contactID => contactDetails ]
6a488035 1266 // also call a hook and get token details
590111ef 1267 CRM_Utils_Hook::tokenValues($contactDetails,
6a488035
TO
1268 $contactIDs,
1269 $jobID,
1270 $tokens,
1271 $className
1272 );
1273 return $details;
1274 }
1275
d20c4dad
EM
1276 /**
1277 * Call hooks on tokens for anonymous users - contact id is set to 0 - this allows non-contact
1278 * specific tokens to be rendered
1279 *
77855840
TO
1280 * @param array $contactIDs
1281 * This should always be array(0) or its not anonymous - left to keep signature same.
16b10e64 1282 * as main fn
d20c4dad 1283 * @param string $returnProperties
77855840
TO
1284 * @param bool $skipOnHold
1285 * @param bool $skipDeceased
d20c4dad
EM
1286 * @param string $extraParams
1287 * @param array $tokens
77855840
TO
1288 * @param string $className
1289 * Sent as context to the hook.
d20c4dad 1290 * @param string $jobID
a6c01b45
CW
1291 * @return array
1292 * contactDetails with hooks swapped out
d20c4dad 1293 */
590111ef 1294 public static function getAnonymousTokenDetails($contactIDs = [0],
353ffa53
TO
1295 $returnProperties = NULL,
1296 $skipOnHold = TRUE,
1297 $skipDeceased = TRUE,
1298 $extraParams = NULL,
be2fb01f 1299 $tokens = [],
353ffa53
TO
1300 $className = NULL,
1301 $jobID = NULL) {
be2fb01f 1302 $details = [0 => []];
e7292422
TO
1303 // also call a hook and get token details
1304 CRM_Utils_Hook::tokenValues($details[0],
d20c4dad
EM
1305 $contactIDs,
1306 $jobID,
1307 $tokens,
1308 $className
1309 );
1310 return $details;
1311 }
e39893f5 1312
2d3e3c7b 1313 /**
fe482240 1314 * Get Membership Token Details.
77855840
TO
1315 * @param array $membershipIDs
1316 * Array of membership IDS.
2d3e3c7b 1317 */
00be9182 1318 public static function getMembershipTokenDetails($membershipIDs) {
be2fb01f
CW
1319 $memberships = civicrm_api3('membership', 'get', [
1320 'options' => ['limit' => 0],
1321 'membership_id' => ['IN' => (array) $membershipIDs],
1322 ]);
2d3e3c7b 1323 return $memberships['values'];
1324 }
353ffa53 1325
6a488035 1326 /**
50bfb460 1327 * Replace existing greeting tokens in message/subject.
54957108 1328 *
206f0198
CB
1329 * This function operates by reference, modifying the first parameter. Other
1330 * methods for token replacement in this class return the modified string.
1331 * This leads to inconsistency in how these methods must be applied.
1332 *
1333 * @TODO Remove that inconsistency in usage.
1334 *
1335 * ::replaceContactTokens() may need to be called after this method, to
1336 * replace tokens supplied from this method.
1337 *
54957108 1338 * @param string $tokenString
1339 * @param array $contactDetails
1340 * @param int $contactId
1341 * @param string $className
1342 * @param bool $escapeSmarty
6a488035 1343 */
00be9182 1344 public static function replaceGreetingTokens(&$tokenString, $contactDetails = NULL, $contactId = NULL, $className = NULL, $escapeSmarty = FALSE) {
6a488035
TO
1345
1346 if (!$contactDetails && !$contactId) {
1347 return;
1348 }
1349
1350 // check if there are any tokens
1351 $greetingTokens = self::getTokens($tokenString);
1352
1353 if (!empty($greetingTokens)) {
1354 // first use the existing contact object for token replacement
1355 if (!empty($contactDetails)) {
73d64eb6 1356 $tokenString = CRM_Utils_Token::replaceContactTokens($tokenString, $contactDetails, TRUE, $greetingTokens, TRUE, $escapeSmarty);
6a488035
TO
1357 }
1358
578c7a3a 1359 self::removeNullContactTokens($tokenString, $contactDetails, $greetingTokens);
6a488035
TO
1360 // check if there are any unevaluated tokens
1361 $greetingTokens = self::getTokens($tokenString);
1362
e7483cbe
J
1363 // $greetingTokens not empty, means there are few tokens which are not
1364 // evaluated, like custom data etc
6a488035
TO
1365 // so retrieve it from database
1366 if (!empty($greetingTokens) && array_key_exists('contact', $greetingTokens)) {
1367 $greetingsReturnProperties = array_flip(CRM_Utils_Array::value('contact', $greetingTokens));
1368 $greetingsReturnProperties = array_fill_keys(array_keys($greetingsReturnProperties), 1);
be2fb01f 1369 $contactParams = ['contact_id' => $contactId];
6a488035
TO
1370
1371 $greetingDetails = self::getTokenDetails($contactParams,
1372 $greetingsReturnProperties,
1373 FALSE, FALSE, NULL,
1374 $greetingTokens,
1375 $className
1376 );
1377
1378 // again replace tokens
1379 $tokenString = CRM_Utils_Token::replaceContactTokens($tokenString,
1380 $greetingDetails,
1381 TRUE,
73d64eb6 1382 $greetingTokens,
f551f7d3 1383 TRUE,
73d64eb6 1384 $escapeSmarty
6a488035
TO
1385 );
1386 }
d75f2f47 1387
ac21a108 1388 // check if there are still any unevaluated tokens
562cf4d7 1389 $remainingTokens = self::getTokens($tokenString);
ac21a108 1390
f551f7d3
NH
1391 // $greetingTokens not empty, there are customized or hook tokens to replace
1392 if (!empty($remainingTokens)) {
ac21a108 1393 // Fill the return properties array
f551f7d3 1394 $greetingTokens = $remainingTokens;
ac21a108 1395 reset($greetingTokens);
be2fb01f 1396 $greetingsReturnProperties = [];
78f2de98
SL
1397 foreach ($greetingTokens as $value) {
1398 $props = array_flip($value);
ac21a108
JM
1399 $props = array_fill_keys(array_keys($props), 1);
1400 $greetingsReturnProperties = $greetingsReturnProperties + $props;
1401 }
be2fb01f 1402 $contactParams = ['contact_id' => $contactId];
ac21a108
JM
1403 $greetingDetails = self::getTokenDetails($contactParams,
1404 $greetingsReturnProperties,
1405 FALSE, FALSE, NULL,
1406 $greetingTokens,
1407 $className
1408 );
1409 // Prepare variables for calling replaceHookTokens
1410 $categories = array_keys($greetingTokens);
1411 list($contact) = $greetingDetails;
1412 // Replace tokens defined in Hooks.
1413 $tokenString = CRM_Utils_Token::replaceHookTokens($tokenString, $contact[$contactId], $categories);
1414 }
6a488035
TO
1415 }
1416 }
1417
663cc0b4
J
1418 /**
1419 * At this point, $contactDetails has loaded the contact from the DAO. Any
1420 * (non-custom) missing fields are null. By removing them, we can avoid
1421 * expensive calls to CRM_Contact_BAO_Query.
1422 *
1423 * @param string $tokenString
1424 * @param array $contactDetails
6714d8d2 1425 * @param array $greetingTokens
663cc0b4
J
1426 */
1427 private static function removeNullContactTokens(&$tokenString, $contactDetails, &$greetingTokens) {
dcdff6e6 1428
a2a7ed37
JK
1429 // Only applies to contact tokens
1430 if (!array_key_exists('contact', $greetingTokens)) {
1431 return;
1432 }
dcdff6e6 1433
663cc0b4
J
1434 $greetingTokensOriginal = $greetingTokens;
1435 $contactFieldList = CRM_Contact_DAO_Contact::fields();
578c7a3a
J
1436 // Sometimes contactDetails are in a multidemensional array, sometimes a
1437 // single-dimension array.
1438 if (array_key_exists(0, $contactDetails) && is_array($contactDetails[0])) {
1439 $contactDetails = current($contactDetails[0]);
1440 }
663cc0b4
J
1441 $nullFields = array_keys(array_diff_key($contactFieldList, $contactDetails));
1442
1443 // Handle legacy tokens
1444 foreach (self::legacyContactTokens() as $oldToken => $newToken) {
1445 if (CRM_Utils_Array::key($newToken, $nullFields)) {
1446 $nullFields[] = $oldToken;
1447 }
1448 }
1449
1450 // Remove null contact fields from $greetingTokens
1451 $greetingTokens['contact'] = array_diff($greetingTokens['contact'], $nullFields);
1452
1453 // Also remove them from $tokenString
1454 $removedTokens = array_diff($greetingTokensOriginal['contact'], $greetingTokens['contact']);
1455 // Handle legacy tokens again, sigh
1456 if (!empty($removedTokens)) {
1457 foreach ($removedTokens as $token) {
1458 if (CRM_Utils_Array::value($token, self::legacyContactTokens()) !== NULL) {
1459 $removedTokens[] = CRM_Utils_Array::value($token, self::legacyContactTokens());
1460 }
1461 }
1462 foreach ($removedTokens as $token) {
1463 $tokenString = str_replace("{contact.$token}", '', $tokenString);
1464 }
1465 }
1466 }
1467
5bc392e6
EM
1468 /**
1469 * @param $tokens
1470 *
1471 * @return array
1472 */
00be9182 1473 public static function flattenTokens(&$tokens) {
be2fb01f 1474 $flattenTokens = [];
6a488035 1475
be2fb01f 1476 foreach ([
6714d8d2
SL
1477 'html',
1478 'text',
1479 'subject',
1480 ] as $prop) {
6a488035
TO
1481 if (!isset($tokens[$prop])) {
1482 continue;
1483 }
1484 foreach ($tokens[$prop] as $type => $names) {
1485 if (!isset($flattenTokens[$type])) {
be2fb01f 1486 $flattenTokens[$type] = [];
6a488035
TO
1487 }
1488 foreach ($names as $name) {
1489 $flattenTokens[$type][$name] = 1;
1490 }
1491 }
1492 }
1493
1494 return $flattenTokens;
1495 }
1496
1497 /**
1498 * Replace all user tokens in $str
1499 *
77855840
TO
1500 * @param string $str
1501 * The string with tokens to be replaced.
e39893f5
EM
1502 *
1503 * @param null $knownTokens
1504 * @param bool $escapeSmarty
6a488035 1505 *
a6c01b45
CW
1506 * @return string
1507 * The processed string
6a488035
TO
1508 */
1509 public static function &replaceUserTokens($str, $knownTokens = NULL, $escapeSmarty = FALSE) {
1510 $key = 'user';
1511 if (!$knownTokens ||
1512 !isset($knownTokens[$key])
1513 ) {
1514 return $str;
1515 }
1516
8bab0eb0
DL
1517 $str = preg_replace_callback(
1518 self::tokenRegex($key),
353ffa53
TO
1519 function ($matches) use ($escapeSmarty) {
1520 return CRM_Utils_Token::getUserTokenReplacement($matches[1], $escapeSmarty);
8bab0eb0
DL
1521 },
1522 $str
6a488035
TO
1523 );
1524 return $str;
1525 }
1526
e39893f5
EM
1527 /**
1528 * @param $token
1529 * @param bool $escapeSmarty
1530 *
1531 * @return string
1532 */
6a488035
TO
1533 public static function getUserTokenReplacement($token, $escapeSmarty = FALSE) {
1534 $value = '';
1535
1536 list($objectName, $objectValue) = explode('-', $token, 2);
1537
1538 switch ($objectName) {
1539 case 'permission':
1540 $value = CRM_Core_Permission::permissionEmails($objectValue);
1541 break;
1542
1543 case 'role':
1544 $value = CRM_Core_Permission::roleEmails($objectValue);
1545 break;
1546 }
1547
1548 if ($escapeSmarty) {
1549 $value = self::tokenEscapeSmarty($value);
1550 }
1551
1552 return $value;
1553 }
1554
6a488035
TO
1555 protected static function _buildContributionTokens() {
1556 $key = 'contribution';
1557 if (self::$_tokens[$key] == NULL) {
1558 self::$_tokens[$key] = array_keys(array_merge(CRM_Contribute_BAO_Contribution::exportableFields('All'),
be2fb01f 1559 ['campaign', 'financial_type'],
18c017c8 1560 self::getCustomFieldTokens('Contribution')
353ffa53 1561 ));
6a488035
TO
1562 }
1563 }
1564
2d3e3c7b 1565 /**
fe482240 1566 * Store membership tokens on the static _tokens array.
2d3e3c7b 1567 */
1568 protected static function _buildMembershipTokens() {
1569 $key = 'membership';
21d6154c 1570 if (!isset(self::$_tokens[$key]) || self::$_tokens[$key] == NULL) {
be2fb01f 1571 $membershipTokens = [];
2d3e3c7b 1572 $tokens = CRM_Core_SelectValues::membershipTokens();
1573 foreach ($tokens as $token => $dontCare) {
1574 $membershipTokens[] = substr($token, (strpos($token, '.') + 1), -1);
1575 }
1576 self::$_tokens[$key] = $membershipTokens;
1577 }
1578 }
1579
1580 /**
fe482240 1581 * Replace tokens for an entity.
2d3e3c7b 1582 * @param string $entity
77855840
TO
1583 * @param array $entityArray
1584 * (e.g. in format from api).
1585 * @param string $str
1586 * String to replace in.
1587 * @param array $knownTokens
1588 * Array of tokens present.
1589 * @param bool $escapeSmarty
a6c01b45
CW
1590 * @return string
1591 * string with replacements made
2d3e3c7b 1592 */
be2fb01f 1593 public static function replaceEntityTokens($entity, $entityArray, $str, $knownTokens = [], $escapeSmarty = FALSE) {
8cc574cf 1594 if (!$knownTokens || empty($knownTokens[$entity])) {
2d3e3c7b 1595 return $str;
1596 }
1597
07945b3c 1598 $fn = 'get' . ucfirst($entity) . 'TokenReplacement';
be2fb01f 1599 $fn = is_callable(['CRM_Utils_Token', $fn]) ? $fn : 'getApiTokenReplacement';
50bfb460 1600 // since we already know the tokens lets just use them & do str_replace which is faster & simpler than preg_replace
2d3e3c7b 1601 foreach ($knownTokens[$entity] as $token) {
07945b3c
CW
1602 $replacement = self::$fn($entity, $token, $entityArray);
1603 if ($escapeSmarty) {
1604 $replacement = self::tokenEscapeSmarty($replacement);
1605 }
1606 $str = str_replace('{' . $entity . '.' . $token . '}', $replacement, $str);
2d3e3c7b 1607 }
07945b3c
CW
1608 return preg_replace('/\\\\|\{(\s*)?\}/', ' ', $str);
1609 }
1610
1611 /**
1612 * @param int $caseId
f274450a 1613 * @param string $str
07945b3c
CW
1614 * @param array $knownTokens
1615 * @param bool $escapeSmarty
1616 * @return string
1617 * @throws \CiviCRM_API3_Exception
1618 */
be2fb01f 1619 public static function replaceCaseTokens($caseId, $str, $knownTokens = [], $escapeSmarty = FALSE) {
07945b3c
CW
1620 if (!$knownTokens || empty($knownTokens['case'])) {
1621 return $str;
1622 }
be2fb01f 1623 $case = civicrm_api3('case', 'getsingle', ['id' => $caseId]);
07945b3c
CW
1624 return self::replaceEntityTokens('case', $case, $str, $knownTokens, $escapeSmarty);
1625 }
1626
1627 /**
1628 * Generic function for formatting token replacement for an api field
1629 *
1630 * @param string $entity
1631 * @param string $token
1632 * @param array $entityArray
1633 * @return string
1634 * @throws \CiviCRM_API3_Exception
1635 */
1636 public static function getApiTokenReplacement($entity, $token, $entityArray) {
1637 if (!isset($entityArray[$token])) {
1638 return '';
1639 }
be2fb01f 1640 $field = civicrm_api3($entity, 'getfield', ['action' => 'get', 'name' => $token, 'get_options' => 'get']);
07945b3c 1641 $field = $field['values'];
9c1bc317 1642 $fieldType = $field['type'] ?? NULL;
6f9518ec
CW
1643 // Boolean fields
1644 if ($fieldType == CRM_Utils_Type::T_BOOLEAN && empty($field['options'])) {
be2fb01f 1645 $field['options'] = [ts('No'), ts('Yes')];
6f9518ec 1646 }
07945b3c
CW
1647 // Match pseudoconstants
1648 if (!empty($field['options'])) {
be2fb01f 1649 $ret = [];
07945b3c
CW
1650 foreach ((array) $entityArray[$token] as $val) {
1651 $ret[] = $field['options'][$val];
1652 }
1653 return implode(', ', $ret);
1654 }
6f9518ec
CW
1655 // Format date fields
1656 elseif ($entityArray[$token] && $fieldType == CRM_Utils_Type::T_DATE) {
07945b3c
CW
1657 return CRM_Utils_Date::customFormat($entityArray[$token]);
1658 }
6f9518ec 1659 return implode(', ', (array) $entityArray[$token]);
2d3e3c7b 1660 }
1661
383c047b 1662 /**
fe482240 1663 * Replace Contribution tokens in html.
e39893f5 1664 *
115dba92
EM
1665 * @param string $str
1666 * @param array $contribution
e39893f5 1667 * @param bool|string $html
383c047b 1668 * @param string $knownTokens
e39893f5
EM
1669 * @param bool|string $escapeSmarty
1670 *
72b3a70c 1671 * @return mixed
383c047b
DG
1672 */
1673 public static function replaceContributionTokens($str, &$contribution, $html = FALSE, $knownTokens = NULL, $escapeSmarty = FALSE) {
1674 $key = 'contribution';
b99f3e96 1675 if (!$knownTokens || empty($knownTokens[$key])) {
6714d8d2
SL
1676 //early return
1677 return $str;
383c047b 1678 }
6a488035
TO
1679 self::_buildContributionTokens();
1680
1681 // here we intersect with the list of pre-configured valid tokens
1682 // so that we remove anything we do not recognize
1683 // I hope to move this step out of here soon and
1684 // then we will just iterate on a list of tokens that are passed to us
6a488035 1685
8bab0eb0
DL
1686 $str = preg_replace_callback(
1687 self::tokenRegex($key),
353ffa53
TO
1688 function ($matches) use (&$contribution, $html, $escapeSmarty) {
1689 return CRM_Utils_Token::getContributionTokenReplacement($matches[1], $contribution, $html, $escapeSmarty);
8bab0eb0 1690 },
6a488035
TO
1691 $str
1692 );
1693
1694 $str = preg_replace('/\\\\|\{(\s*)?\}/', ' ', $str);
1695 return $str;
1696 }
1697
383c047b
DG
1698 /**
1699 * We have a situation where we are rendering more than one token in each field because we are combining
1700 * tokens from more than one contribution when pdf thank you letters are grouped (CRM-14367)
1701 *
1702 * The replaceContributionToken doesn't handle receive_date correctly in this scenario because of the formatting
1703 * it applies (other tokens are OK including date fields)
1704 *
1705 * So we sort this out & then call the main function. Note that we are not escaping smarty on this fields like the main function
1706 * does - but the fields is already being formatted through a date function
1707 *
1708 * @param string $separator
1709 * @param string $str
1710 * @param array $contribution
e39893f5 1711 * @param bool|string $html
383c047b 1712 * @param string $knownTokens
e39893f5
EM
1713 * @param bool|string $escapeSmarty
1714 *
6f9518ec 1715 * @return string
383c047b
DG
1716 */
1717 public static function replaceMultipleContributionTokens($separator, $str, &$contribution, $html = FALSE, $knownTokens = NULL, $escapeSmarty = FALSE) {
22e263ad 1718 if (empty($knownTokens['contribution'])) {
383c047b
DG
1719 return $str;
1720 }
1721
22e263ad 1722 if (in_array('receive_date', $knownTokens['contribution'])) {
be2fb01f 1723 $formattedDates = [];
383c047b
DG
1724 $dates = explode($separator, $contribution['receive_date']);
1725 foreach ($dates as $date) {
be2fb01f 1726 $formattedDates[] = CRM_Utils_Date::customFormat($date, NULL, ['j', 'm', 'Y']);
383c047b
DG
1727 }
1728 $str = str_replace("{contribution.receive_date}", implode($separator, $formattedDates), $str);
1729 unset($knownTokens['contribution']['receive_date']);
1730 }
1731 return self::replaceContributionTokens($str, $contribution, $html, $knownTokens, $escapeSmarty);
1732 }
1733
2d3e3c7b 1734 /**
1735 * Get replacement strings for any membership tokens (only a small number of tokens are implemnted in the first instance
1736 * - this is used by the pdfLetter task from membership search
6f9518ec
CW
1737 * @param string $entity
1738 * should always be "membership"
2d3e3c7b 1739 * @param string $token
6f9518ec 1740 * field name
77855840
TO
1741 * @param array $membership
1742 * An api result array for a single membership.
6f9518ec 1743 * @return string token replacement
2d3e3c7b 1744 */
07945b3c 1745 public static function getMembershipTokenReplacement($entity, $token, $membership) {
2d3e3c7b 1746 self::_buildMembershipTokens();
e7292422
TO
1747 switch ($token) {
1748 case 'type':
1749 $value = $membership['membership_name'];
1750 break;
1751
1752 case 'status':
1753 $statuses = CRM_Member_BAO_Membership::buildOptions('status_id');
1754 $value = $statuses[$membership['status_id']];
1755 break;
1756
1757 case 'fee':
92e4c2a5 1758 try {
be2fb01f 1759 $value = civicrm_api3('membership_type', 'getvalue', [
6714d8d2
SL
1760 'id' => $membership['membership_type_id'],
1761 'return' => 'minimum_fee',
1762 ]);
daec3f08 1763 $value = CRM_Utils_Money::format($value, NULL, NULL, TRUE);
e7292422
TO
1764 }
1765 catch (CiviCRM_API3_Exception $e) {
1766 // we can anticipate we will get an error if the minimum fee is set to 'NULL' because of the way the
1767 // api handles NULL (4.4)
1768 $value = 0;
1769 }
1770 break;
1771
1772 default:
1773 if (in_array($token, self::$_tokens[$entity])) {
1774 $value = $membership[$token];
7ab8b45b 1775 if (CRM_Utils_String::endsWith($token, '_date')) {
1776 $value = CRM_Utils_Date::customFormat($value);
1777 }
e7292422
TO
1778 }
1779 else {
50bfb460 1780 // ie unchanged
e7292422
TO
1781 $value = "{$entity}.{$token}";
1782 }
1783 break;
2d3e3c7b 1784 }
1785
2d3e3c7b 1786 return $value;
1787 }
1788
e39893f5
EM
1789 /**
1790 * @param $token
1791 * @param $contribution
1792 * @param bool $html
1793 * @param bool $escapeSmarty
1794 *
1795 * @return mixed|string
1796 */
6a488035
TO
1797 public static function getContributionTokenReplacement($token, &$contribution, $html = FALSE, $escapeSmarty = FALSE) {
1798 self::_buildContributionTokens();
1799
1800 switch ($token) {
1801 case 'total_amount':
1802 case 'net_amount':
1803 case 'fee_amount':
1804 case 'non_deductible_amount':
1805 $value = CRM_Utils_Money::format(CRM_Utils_Array::retrieveValueRecursive($contribution, $token));
1806 break;
1807
1808 case 'receive_date':
1809 $value = CRM_Utils_Array::retrieveValueRecursive($contribution, $token);
be2fb01f 1810 $value = CRM_Utils_Date::customFormat($value, NULL, ['j', 'm', 'Y']);
6a488035
TO
1811 break;
1812
1813 default:
1814 if (!in_array($token, self::$_tokens['contribution'])) {
1815 $value = "{contribution.$token}";
1816 }
1817 else {
1818 $value = CRM_Utils_Array::retrieveValueRecursive($contribution, $token);
1819 }
1820 break;
1821 }
1822
6a488035
TO
1823 if ($escapeSmarty) {
1824 $value = self::tokenEscapeSmarty($value);
1825 }
1826 return $value;
1827 }
1828
091133bb 1829 /**
a6c01b45 1830 * @return array
6f9518ec 1831 * [legacy_token => new_token]
091133bb 1832 */
00be9182 1833 public static function legacyContactTokens() {
be2fb01f 1834 return [
091133bb
CW
1835 'individual_prefix' => 'prefix_id',
1836 'individual_suffix' => 'suffix_id',
1837 'gender' => 'gender_id',
aa62b355 1838 'communication_style' => 'communication_style_id',
be2fb01f 1839 ];
091133bb
CW
1840 }
1841
18c017c8 1842 /**
1843 * Get all custom field tokens of $entity
1844 *
1845 * @param string $entity
1846 * @param bool $usedForTokenWidget
1847 *
6714d8d2 1848 * @return array
18c017c8 1849 * return custom field tokens in array('custom_N' => 'label') format
1850 */
1851 public static function getCustomFieldTokens($entity, $usedForTokenWidget = FALSE) {
be2fb01f 1852 $customTokens = [];
18c017c8 1853 $tokenName = $usedForTokenWidget ? "{contribution.custom_%d}" : "custom_%d";
1854 foreach (CRM_Core_BAO_CustomField::getFields($entity) as $id => $info) {
1855 $customTokens[sprintf($tokenName, $id)] = $info['label'];
1856 }
1857
1858 return $customTokens;
1859 }
1860
ac0a3db5
CW
1861 /**
1862 * Formats a token list for the select2 widget
9950a1c9 1863 *
ac0a3db5
CW
1864 * @param $tokens
1865 * @return array
1866 */
00be9182 1867 public static function formatTokensForDisplay($tokens) {
be2fb01f 1868 $sorted = $output = [];
ac0a3db5
CW
1869
1870 // Sort in ascending order by ignoring word case
1871 natcasesort($tokens);
1872
1873 // Attempt to place tokens into optgroups
50bfb460 1874 // @todo These groupings could be better and less hackish. Getting them pre-grouped from upstream would be nice.
ac0a3db5
CW
1875 foreach ($tokens as $k => $v) {
1876 // Check to see if this token is already in a group e.g. for custom fields
1877 $split = explode(' :: ', $v);
1878 if (!empty($split[1])) {
be2fb01f 1879 $sorted[$split[1]][] = ['id' => $k, 'text' => $split[0]];
ac0a3db5
CW
1880 }
1881 // Group by entity
1882 else {
1883 $split = explode('.', trim($k, '{}'));
cc666210 1884 if (isset($split[1])) {
f274450a 1885 $entity = array_key_exists($split[1], CRM_Core_DAO_Address::export()) ? 'Address' : ucwords(str_replace('_', ' ', $split[0]));
cc666210
CW
1886 }
1887 else {
1888 $entity = 'Contact';
1889 }
be2fb01f 1890 $sorted[ts($entity)][] = ['id' => $k, 'text' => $v];
ac0a3db5
CW
1891 }
1892 }
1893
1894 ksort($sorted);
1895 foreach ($sorted as $k => $v) {
be2fb01f 1896 $output[] = ['text' => $k, 'children' => $v];
ac0a3db5
CW
1897 }
1898
1899 return $output;
1900 }
96025800 1901
6a488035 1902}