Merge pull request #21286 from vingle/patch-13
[civicrm-core.git] / Civi / Token / TokenProcessor.php
CommitLineData
8adcd073
TO
1<?php
2namespace Civi\Token;
3
4use Civi\Token\Event\TokenRegisterEvent;
5use Civi\Token\Event\TokenRenderEvent;
6use Civi\Token\Event\TokenValueEvent;
8adcd073
TO
7use Traversable;
8
cef60558
TO
9/**
10 * The TokenProcessor is a template/token-engine. It is heavily influenced by
11 * traditional expectations of CiviMail, but it's adapted to an object-oriented,
12 * extensible design.
13 *
14 * BACKGROUND
15 *
16 * The CiviMail heritage gives the following expectations:
17 *
18 * - Messages are often composed of multiple parts (e.g. HTML-part, text-part, and subject-part).
19 * - Messages are often composed in batches for multiple recipients.
20 * - Tokens are denoted as `{foo.bar}`.
21 * - Data should be loaded in an optimized fashion - fetch only the needed
22 * columns, and fetch them with one query (per-table).
23 *
24 * The question of "optimized" data-loading is a key differentiator/complication.
25 * This requires some kind of communication/integration between the template-parser and data-loader.
26 *
27 * USAGE
28 *
29 * There are generally two perspectives on using TokenProcessor:
30 *
31 * 1. Composing messages: You need to specify the template contents (eg `addMessage(...)`)
32 * and the recipients' key data (eg `addRow(['contact_id' => 123])`).
33 * 2. Defining tokens/entities/data-loaders: You need to listen for TokenProcessor
34 * events; if any of your tokens/entities are used, then load the batch of data.
35 *
36 * Each use-case is presented with examples in the Developer Guide:
37 *
38 * @link https://docs.civicrm.org/dev/en/latest/framework/token/
39 */
8adcd073
TO
40class TokenProcessor {
41
42 /**
43 * @var array
44 * Description of the context in which the tokens are being processed.
45 * Ex: Array('class'=>'CRM_Core_BAO_ActionSchedule', 'schedule' => $dao, 'mapping' => $dao).
46 * Ex: Array('class'=>'CRM_Mailing_BAO_MailingJob', 'mailing' => $dao).
47 *
48 * For lack of a better place, here's a list of known/intended context values:
49 *
50 * - controller: string, the class which is managing the mail-merge.
51 * - smarty: bool, whether to enable smarty support.
52 * - contactId: int, the main person/org discussed in the message.
53 * - contact: array, the main person/org discussed in the message.
54 * (Optional for performance tweaking; if omitted, will load
55 * automatically from contactId.)
56 * - actionSchedule: DAO, the rule which triggered the mailing
57 * [for CRM_Core_BAO_ActionScheduler].
4b7b899b 58 * - locale: string, the name of a locale (eg 'fr_CA') to use for {ts} strings in the view.
37f37651
AS
59 * - schema: array, a list of fields that will be provided for each row.
60 * This is automatically populated with any general context
61 * keys, but you may need to add extra keys for token-row data.
51e25a23 62 * ex: ['contactId', 'activityId'].
8adcd073
TO
63 */
64 public $context;
65
66 /**
34f3bbd9 67 * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
8adcd073
TO
68 */
69 protected $dispatcher;
70
71 /**
72 * @var array
73 * Each message is an array with keys:
74 * - string: Unprocessed message (eg "Hello, {display_name}.").
75 * - format: Media type (eg "text/plain").
76 * - tokens: List of tokens which are actually used in this message.
77 */
78 protected $messages;
79
80 /**
81 * DO NOT access field this directly. Use TokenRow. This is
82 * marked as public only to benefit TokenRow.
83 *
84 * @var array
85 * Array(int $pos => array $keyValues);
86 */
87 public $rowContexts;
88
89 /**
90 * DO NOT access field this directly. Use TokenRow. This is
91 * marked as public only to benefit TokenRow.
92 *
93 * @var array
94 * Ex: $rowValues[$rowPos][$format][$entity][$field] = 'something';
95 * Ex: $rowValues[3]['text/plain']['contact']['display_name'] = 'something';
96 */
97 public $rowValues;
98
99 /**
100 * A list of available tokens
101 * @var array
102 * Array(string $dottedName => array('entity'=>string, 'field'=>string, 'label'=>string)).
103 */
104 protected $tokens = NULL;
105
1c4a04c9
AS
106 /**
107 * A list of available tokens formatted for display
108 * @var array
109 * Array('{' . $dottedName . '}' => 'labelString')
110 */
111 protected $listTokens = NULL;
112
8adcd073
TO
113 protected $next = 0;
114
115 /**
34f3bbd9 116 * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
8adcd073
TO
117 * @param array $context
118 */
119 public function __construct($dispatcher, $context) {
37f37651
AS
120 $context['schema'] = isset($context['schema'])
121 ? array_unique(array_merge($context['schema'], array_keys($context)))
122 : array_keys($context);
8adcd073
TO
123 $this->dispatcher = $dispatcher;
124 $this->context = $context;
125 }
126
127 /**
128 * Register a string for which we'll need to merge in tokens.
129 *
130 * @param string $name
131 * Ex: 'subject', 'body_html'.
132 * @param string $value
133 * Ex: '<p>Hello {contact.name}</p>'.
134 * @param string $format
135 * Ex: 'text/html'.
4b350175 136 * @return TokenProcessor
8adcd073
TO
137 */
138 public function addMessage($name, $value, $format) {
c64f69d9 139 $this->messages[$name] = [
8adcd073
TO
140 'string' => $value,
141 'format' => $format,
142 'tokens' => \CRM_Utils_Token::getTokens($value),
c64f69d9 143 ];
8adcd073
TO
144 return $this;
145 }
146
147 /**
148 * Add a row of data.
149 *
3faff3fc
TO
150 * @param array|NULL $context
151 * Optionally, initialize the context for this row.
152 * Ex: ['contact_id' => 123].
8adcd073
TO
153 * @return TokenRow
154 */
3faff3fc 155 public function addRow($context = NULL) {
8adcd073 156 $key = $this->next++;
c64f69d9
CW
157 $this->rowContexts[$key] = [];
158 $this->rowValues[$key] = [
159 'text/plain' => [],
160 'text/html' => [],
161 ];
8adcd073 162
3faff3fc
TO
163 $row = new TokenRow($this, $key);
164 if ($context !== NULL) {
165 $row->context($context);
166 }
167 return $row;
168 }
169
170 /**
171 * Add several rows.
172 *
173 * @param array $contexts
174 * List of rows to add.
175 * Ex: [['contact_id'=>123], ['contact_id'=>456]]
176 * @return TokenRow[]
177 * List of row objects
178 */
179 public function addRows($contexts) {
180 $rows = [];
181 foreach ($contexts as $context) {
182 $row = $this->addRow($context);
183 $rows[$row->tokenRow] = $row;
184 }
185 return $rows;
8adcd073
TO
186 }
187
188 /**
189 * @param array $params
190 * Array with keys:
191 * - entity: string, e.g. "profile".
192 * - field: string, e.g. "viewUrl".
193 * - label: string, e.g. "Default Profile URL (View Mode)".
4b350175 194 * @return TokenProcessor
8adcd073
TO
195 */
196 public function addToken($params) {
197 $key = $params['entity'] . '.' . $params['field'];
198 $this->tokens[$key] = $params;
199 return $this;
200 }
201
202 /**
203 * @param string $name
204 * @return array
205 * Keys:
206 * - string: Unprocessed message (eg "Hello, {display_name}.").
207 * - format: Media type (eg "text/plain").
208 */
209 public function getMessage($name) {
210 return $this->messages[$name];
211 }
212
213 /**
214 * Get a list of all tokens used in registered messages.
215 *
216 * @return array
cef60558
TO
217 * The list of activated tokens, indexed by object/entity.
218 * Array(string $entityName => string[] $fieldNames)
219 *
220 * Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
221 * then $result['contact'] would be ['first_name', 'last_name'].
8adcd073
TO
222 */
223 public function getMessageTokens() {
c64f69d9 224 $tokens = [];
8adcd073
TO
225 foreach ($this->messages as $message) {
226 $tokens = \CRM_Utils_Array::crmArrayMerge($tokens, $message['tokens']);
227 }
228 foreach (array_keys($tokens) as $e) {
229 $tokens[$e] = array_unique($tokens[$e]);
230 sort($tokens[$e]);
231 }
232 return $tokens;
233 }
234
cef60558
TO
235 /**
236 * Get a specific row (i.e. target or recipient).
237 *
238 * Ex: echo $p->getRow(2)->context['contact_id'];
239 * Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
240 *
241 * @param int $key
242 * The row ID
243 * @return \Civi\Token\TokenRow
244 * The row is presented with a fluent, OOP facade.
245 * @see TokenRow
246 */
8adcd073
TO
247 public function getRow($key) {
248 return new TokenRow($this, $key);
249 }
250
251 /**
cef60558
TO
252 * Get the list of rows (i.e. targets/recipients to generate).
253 *
254 * @see TokenRow
8adcd073 255 * @return \Traversable<TokenRow>
cef60558 256 * Each row is presented with a fluent, OOP facade.
8adcd073
TO
257 */
258 public function getRows() {
c06f174f 259 return new TokenRowIterator($this, new \ArrayIterator($this->rowContexts ?: []));
8adcd073
TO
260 }
261
5d798402
AS
262 /**
263 * Get a list of all unique values for a given context field,
264 * whether defined at the processor or row level.
265 *
266 * @param string $field
267 * Ex: 'contactId'.
cef60558 268 * @param string|NULL $subfield
5d798402
AS
269 * @return array
270 * Ex: [12, 34, 56].
271 */
8580ab67 272 public function getContextValues($field, $subfield = NULL) {
5d798402
AS
273 $values = [];
274 if (isset($this->context[$field])) {
8580ab67
AS
275 if ($subfield) {
276 if (isset($this->context[$field]->$subfield)) {
277 $values[] = $this->context[$field]->$subfield;
278 }
279 }
280 else {
281 $values[] = $this->context[$field];
282 }
5d798402
AS
283 }
284 foreach ($this->getRows() as $row) {
285 if (isset($row->context[$field])) {
8580ab67
AS
286 if ($subfield) {
287 if (isset($row->context[$field]->$subfield)) {
288 $values[] = $row->context[$field]->$subfield;
289 }
290 }
291 else {
292 $values[] = $row->context[$field];
293 }
5d798402
AS
294 }
295 }
296 $values = array_unique($values);
297 return $values;
298 }
299
8adcd073
TO
300 /**
301 * Get the list of available tokens.
302 *
303 * @return array
cef60558 304 * Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
8adcd073
TO
305 */
306 public function getTokens() {
307 if ($this->tokens === NULL) {
c64f69d9
CW
308 $this->tokens = [];
309 $event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
4c367668 310 $this->dispatcher->dispatch('civi.token.list', $event);
8adcd073
TO
311 }
312 return $this->tokens;
313 }
314
1c4a04c9
AS
315 /**
316 * Get the list of available tokens, formatted for display
317 *
318 * @return array
cef60558 319 * Ex: $tokens['{token.name}'] = "Token label"
1c4a04c9
AS
320 */
321 public function listTokens() {
322 if ($this->listTokens === NULL) {
c64f69d9 323 $this->listTokens = [];
1c4a04c9
AS
324 foreach ($this->getTokens() as $token => $values) {
325 $this->listTokens['{' . $token . '}'] = $values['label'];
326 }
327 }
328 return $this->listTokens;
329 }
330
8adcd073
TO
331 /**
332 * Compute and store token values.
333 */
334 public function evaluate() {
335 $event = new TokenValueEvent($this);
4c367668 336 $this->dispatcher->dispatch('civi.token.eval', $event);
8adcd073
TO
337 return $this;
338 }
339
340 /**
341 * Render a message.
342 *
343 * @param string $name
344 * The name previously registered with addMessage().
345 * @param TokenRow|int $row
346 * The object or ID for the row previously registered with addRow().
347 * @return string
348 * Fully rendered message, with tokens merged.
349 */
350 public function render($name, $row) {
351 if (!is_object($row)) {
352 $row = $this->getRow($row);
353 }
354
4b7b899b
TO
355 $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
356
8adcd073
TO
357 $message = $this->getMessage($name);
358 $row->fill($message['format']);
359 $useSmarty = !empty($row->context['smarty']);
360
34f3bbd9
SL
361 /**
362 *@FIXME preg_callback.
363 */
8adcd073 364 $tokens = $this->rowValues[$row->tokenRow][$message['format']];
c64f69d9 365 $flatTokens = [];
8adcd073 366 \CRM_Utils_Array::flatten($tokens, $flatTokens, '', '.');
c64f69d9 367 $filteredTokens = [];
8adcd073
TO
368 foreach ($flatTokens as $k => $v) {
369 $filteredTokens['{' . $k . '}'] = ($useSmarty ? \CRM_Utils_Token::tokenEscapeSmarty($v) : $v);
370 }
371
372 $event = new TokenRenderEvent($this);
373 $event->message = $message;
374 $event->context = $row->context;
375 $event->row = $row;
376 $event->string = strtr($message['string'], $filteredTokens);
4c367668 377 $this->dispatcher->dispatch('civi.token.render', $event);
8adcd073
TO
378 return $event->string;
379 }
380
381}
382
383class TokenRowIterator extends \IteratorIterator {
384
385 protected $tokenProcessor;
386
387 /**
388 * @param TokenProcessor $tokenProcessor
34f3bbd9 389 * @param \Traversable $iterator
8adcd073
TO
390 */
391 public function __construct(TokenProcessor $tokenProcessor, Traversable $iterator) {
34f3bbd9
SL
392 // TODO: Change the autogenerated stub
393 parent::__construct($iterator);
8adcd073
TO
394 $this->tokenProcessor = $tokenProcessor;
395 }
396
397 public function current() {
398 return new TokenRow($this->tokenProcessor, parent::key());
399 }
400
401}