Merge pull request #19542 from seamuslee001/afform_fix_array_access
[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].
37f37651
AS
58 * - schema: array, a list of fields that will be provided for each row.
59 * This is automatically populated with any general context
60 * keys, but you may need to add extra keys for token-row data.
51e25a23 61 * ex: ['contactId', 'activityId'].
8adcd073
TO
62 */
63 public $context;
64
65 /**
34f3bbd9 66 * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
8adcd073
TO
67 */
68 protected $dispatcher;
69
70 /**
71 * @var array
72 * Each message is an array with keys:
73 * - string: Unprocessed message (eg "Hello, {display_name}.").
74 * - format: Media type (eg "text/plain").
75 * - tokens: List of tokens which are actually used in this message.
76 */
77 protected $messages;
78
79 /**
80 * DO NOT access field this directly. Use TokenRow. This is
81 * marked as public only to benefit TokenRow.
82 *
83 * @var array
84 * Array(int $pos => array $keyValues);
85 */
86 public $rowContexts;
87
88 /**
89 * DO NOT access field this directly. Use TokenRow. This is
90 * marked as public only to benefit TokenRow.
91 *
92 * @var array
93 * Ex: $rowValues[$rowPos][$format][$entity][$field] = 'something';
94 * Ex: $rowValues[3]['text/plain']['contact']['display_name'] = 'something';
95 */
96 public $rowValues;
97
98 /**
99 * A list of available tokens
100 * @var array
101 * Array(string $dottedName => array('entity'=>string, 'field'=>string, 'label'=>string)).
102 */
103 protected $tokens = NULL;
104
1c4a04c9
AS
105 /**
106 * A list of available tokens formatted for display
107 * @var array
108 * Array('{' . $dottedName . '}' => 'labelString')
109 */
110 protected $listTokens = NULL;
111
8adcd073
TO
112 protected $next = 0;
113
114 /**
34f3bbd9 115 * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
8adcd073
TO
116 * @param array $context
117 */
118 public function __construct($dispatcher, $context) {
37f37651
AS
119 $context['schema'] = isset($context['schema'])
120 ? array_unique(array_merge($context['schema'], array_keys($context)))
121 : array_keys($context);
8adcd073
TO
122 $this->dispatcher = $dispatcher;
123 $this->context = $context;
124 }
125
126 /**
127 * Register a string for which we'll need to merge in tokens.
128 *
129 * @param string $name
130 * Ex: 'subject', 'body_html'.
131 * @param string $value
132 * Ex: '<p>Hello {contact.name}</p>'.
133 * @param string $format
134 * Ex: 'text/html'.
4b350175 135 * @return TokenProcessor
8adcd073
TO
136 */
137 public function addMessage($name, $value, $format) {
c64f69d9 138 $this->messages[$name] = [
8adcd073
TO
139 'string' => $value,
140 'format' => $format,
141 'tokens' => \CRM_Utils_Token::getTokens($value),
c64f69d9 142 ];
8adcd073
TO
143 return $this;
144 }
145
146 /**
147 * Add a row of data.
148 *
3faff3fc
TO
149 * @param array|NULL $context
150 * Optionally, initialize the context for this row.
151 * Ex: ['contact_id' => 123].
8adcd073
TO
152 * @return TokenRow
153 */
3faff3fc 154 public function addRow($context = NULL) {
8adcd073 155 $key = $this->next++;
c64f69d9
CW
156 $this->rowContexts[$key] = [];
157 $this->rowValues[$key] = [
158 'text/plain' => [],
159 'text/html' => [],
160 ];
8adcd073 161
3faff3fc
TO
162 $row = new TokenRow($this, $key);
163 if ($context !== NULL) {
164 $row->context($context);
165 }
166 return $row;
167 }
168
169 /**
170 * Add several rows.
171 *
172 * @param array $contexts
173 * List of rows to add.
174 * Ex: [['contact_id'=>123], ['contact_id'=>456]]
175 * @return TokenRow[]
176 * List of row objects
177 */
178 public function addRows($contexts) {
179 $rows = [];
180 foreach ($contexts as $context) {
181 $row = $this->addRow($context);
182 $rows[$row->tokenRow] = $row;
183 }
184 return $rows;
8adcd073
TO
185 }
186
187 /**
188 * @param array $params
189 * Array with keys:
190 * - entity: string, e.g. "profile".
191 * - field: string, e.g. "viewUrl".
192 * - label: string, e.g. "Default Profile URL (View Mode)".
4b350175 193 * @return TokenProcessor
8adcd073
TO
194 */
195 public function addToken($params) {
196 $key = $params['entity'] . '.' . $params['field'];
197 $this->tokens[$key] = $params;
198 return $this;
199 }
200
201 /**
202 * @param string $name
203 * @return array
204 * Keys:
205 * - string: Unprocessed message (eg "Hello, {display_name}.").
206 * - format: Media type (eg "text/plain").
207 */
208 public function getMessage($name) {
209 return $this->messages[$name];
210 }
211
212 /**
213 * Get a list of all tokens used in registered messages.
214 *
215 * @return array
cef60558
TO
216 * The list of activated tokens, indexed by object/entity.
217 * Array(string $entityName => string[] $fieldNames)
218 *
219 * Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
220 * then $result['contact'] would be ['first_name', 'last_name'].
8adcd073
TO
221 */
222 public function getMessageTokens() {
c64f69d9 223 $tokens = [];
8adcd073
TO
224 foreach ($this->messages as $message) {
225 $tokens = \CRM_Utils_Array::crmArrayMerge($tokens, $message['tokens']);
226 }
227 foreach (array_keys($tokens) as $e) {
228 $tokens[$e] = array_unique($tokens[$e]);
229 sort($tokens[$e]);
230 }
231 return $tokens;
232 }
233
cef60558
TO
234 /**
235 * Get a specific row (i.e. target or recipient).
236 *
237 * Ex: echo $p->getRow(2)->context['contact_id'];
238 * Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
239 *
240 * @param int $key
241 * The row ID
242 * @return \Civi\Token\TokenRow
243 * The row is presented with a fluent, OOP facade.
244 * @see TokenRow
245 */
8adcd073
TO
246 public function getRow($key) {
247 return new TokenRow($this, $key);
248 }
249
250 /**
cef60558
TO
251 * Get the list of rows (i.e. targets/recipients to generate).
252 *
253 * @see TokenRow
8adcd073 254 * @return \Traversable<TokenRow>
cef60558 255 * Each row is presented with a fluent, OOP facade.
8adcd073
TO
256 */
257 public function getRows() {
258 return new TokenRowIterator($this, new \ArrayIterator($this->rowContexts));
259 }
260
5d798402
AS
261 /**
262 * Get a list of all unique values for a given context field,
263 * whether defined at the processor or row level.
264 *
265 * @param string $field
266 * Ex: 'contactId'.
cef60558 267 * @param string|NULL $subfield
5d798402
AS
268 * @return array
269 * Ex: [12, 34, 56].
270 */
8580ab67 271 public function getContextValues($field, $subfield = NULL) {
5d798402
AS
272 $values = [];
273 if (isset($this->context[$field])) {
8580ab67
AS
274 if ($subfield) {
275 if (isset($this->context[$field]->$subfield)) {
276 $values[] = $this->context[$field]->$subfield;
277 }
278 }
279 else {
280 $values[] = $this->context[$field];
281 }
5d798402
AS
282 }
283 foreach ($this->getRows() as $row) {
284 if (isset($row->context[$field])) {
8580ab67
AS
285 if ($subfield) {
286 if (isset($row->context[$field]->$subfield)) {
287 $values[] = $row->context[$field]->$subfield;
288 }
289 }
290 else {
291 $values[] = $row->context[$field];
292 }
5d798402
AS
293 }
294 }
295 $values = array_unique($values);
296 return $values;
297 }
298
8adcd073
TO
299 /**
300 * Get the list of available tokens.
301 *
302 * @return array
cef60558 303 * Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
8adcd073
TO
304 */
305 public function getTokens() {
306 if ($this->tokens === NULL) {
c64f69d9
CW
307 $this->tokens = [];
308 $event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
4c367668 309 $this->dispatcher->dispatch('civi.token.list', $event);
8adcd073
TO
310 }
311 return $this->tokens;
312 }
313
1c4a04c9
AS
314 /**
315 * Get the list of available tokens, formatted for display
316 *
317 * @return array
cef60558 318 * Ex: $tokens['{token.name}'] = "Token label"
1c4a04c9
AS
319 */
320 public function listTokens() {
321 if ($this->listTokens === NULL) {
c64f69d9 322 $this->listTokens = [];
1c4a04c9
AS
323 foreach ($this->getTokens() as $token => $values) {
324 $this->listTokens['{' . $token . '}'] = $values['label'];
325 }
326 }
327 return $this->listTokens;
328 }
329
8adcd073
TO
330 /**
331 * Compute and store token values.
332 */
333 public function evaluate() {
334 $event = new TokenValueEvent($this);
4c367668 335 $this->dispatcher->dispatch('civi.token.eval', $event);
8adcd073
TO
336 return $this;
337 }
338
339 /**
340 * Render a message.
341 *
342 * @param string $name
343 * The name previously registered with addMessage().
344 * @param TokenRow|int $row
345 * The object or ID for the row previously registered with addRow().
346 * @return string
347 * Fully rendered message, with tokens merged.
348 */
349 public function render($name, $row) {
350 if (!is_object($row)) {
351 $row = $this->getRow($row);
352 }
353
354 $message = $this->getMessage($name);
355 $row->fill($message['format']);
356 $useSmarty = !empty($row->context['smarty']);
357
34f3bbd9
SL
358 /**
359 *@FIXME preg_callback.
360 */
8adcd073 361 $tokens = $this->rowValues[$row->tokenRow][$message['format']];
c64f69d9 362 $flatTokens = [];
8adcd073 363 \CRM_Utils_Array::flatten($tokens, $flatTokens, '', '.');
c64f69d9 364 $filteredTokens = [];
8adcd073
TO
365 foreach ($flatTokens as $k => $v) {
366 $filteredTokens['{' . $k . '}'] = ($useSmarty ? \CRM_Utils_Token::tokenEscapeSmarty($v) : $v);
367 }
368
369 $event = new TokenRenderEvent($this);
370 $event->message = $message;
371 $event->context = $row->context;
372 $event->row = $row;
373 $event->string = strtr($message['string'], $filteredTokens);
4c367668 374 $this->dispatcher->dispatch('civi.token.render', $event);
8adcd073
TO
375 return $event->string;
376 }
377
378}
379
380class TokenRowIterator extends \IteratorIterator {
381
382 protected $tokenProcessor;
383
384 /**
385 * @param TokenProcessor $tokenProcessor
34f3bbd9 386 * @param \Traversable $iterator
8adcd073
TO
387 */
388 public function __construct(TokenProcessor $tokenProcessor, Traversable $iterator) {
34f3bbd9
SL
389 // TODO: Change the autogenerated stub
390 parent::__construct($iterator);
8adcd073
TO
391 $this->tokenProcessor = $tokenProcessor;
392 }
393
394 public function current() {
395 return new TokenRow($this->tokenProcessor, parent::key());
396 }
397
398}