/
TokenProcessor.php
480 lines (437 loc) · 14.7 KB
/
TokenProcessor.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
<?php
namespace Civi\Token;
use Civi\Token\Event\TokenRegisterEvent;
use Civi\Token\Event\TokenRenderEvent;
use Civi\Token\Event\TokenValueEvent;
use Traversable;
/**
* The TokenProcessor is a template/token-engine. It is heavily influenced by
* traditional expectations of CiviMail, but it's adapted to an object-oriented,
* extensible design.
*
* BACKGROUND
*
* The CiviMail heritage gives the following expectations:
*
* - Messages are often composed of multiple parts (e.g. HTML-part, text-part, and subject-part).
* - Messages are often composed in batches for multiple recipients.
* - Tokens are denoted as `{foo.bar}`.
* - Data should be loaded in an optimized fashion - fetch only the needed
* columns, and fetch them with one query (per-table).
*
* The question of "optimized" data-loading is a key differentiator/complication.
* This requires some kind of communication/integration between the template-parser and data-loader.
*
* USAGE
*
* There are generally two perspectives on using TokenProcessor:
*
* 1. Composing messages: You need to specify the template contents (eg `addMessage(...)`)
* and the recipients' key data (eg `addRow(['contact_id' => 123])`).
* 2. Defining tokens/entities/data-loaders: You need to listen for TokenProcessor
* events; if any of your tokens/entities are used, then load the batch of data.
*
* Each use-case is presented with examples in the Developer Guide:
*
* @link https://docs.civicrm.org/dev/en/latest/framework/token/
*/
class TokenProcessor {
/**
* @var array
* Description of the context in which the tokens are being processed.
* Ex: Array('class'=>'CRM_Core_BAO_ActionSchedule', 'schedule' => $dao, 'mapping' => $dao).
* Ex: Array('class'=>'CRM_Mailing_BAO_MailingJob', 'mailing' => $dao).
*
* For lack of a better place, here's a list of known/intended context values:
*
* - controller: string, the class which is managing the mail-merge.
* - smarty: bool, whether to enable smarty support.
* - smartyTokenAlias: array, Define Smarty variables that are populated
* based on token-content. Ex: ['theInvoiceId' => 'contribution.invoice_id']
* - contactId: int, the main person/org discussed in the message.
* - contact: array, the main person/org discussed in the message.
* (Optional for performance tweaking; if omitted, will load
* automatically from contactId.)
* - actionSchedule: DAO, the rule which triggered the mailing
* [for CRM_Core_BAO_ActionScheduler].
* - locale: string, the name of a locale (eg 'fr_CA') to use for {ts} strings in the view.
* - schema: array, a list of fields that will be provided for each row.
* This is automatically populated with any general context
* keys, but you may need to add extra keys for token-row data.
* ex: ['contactId', 'activityId'].
*/
public $context;
/**
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var array
* Each message is an array with keys:
* - string: Unprocessed message (eg "Hello, {display_name}.").
* - format: Media type (eg "text/plain").
* - tokens: List of tokens which are actually used in this message.
*/
protected $messages;
/**
* DO NOT access field this directly. Use TokenRow. This is
* marked as public only to benefit TokenRow.
*
* @var array
* Array(int $pos => array $keyValues);
*/
public $rowContexts;
/**
* DO NOT access field this directly. Use TokenRow. This is
* marked as public only to benefit TokenRow.
*
* @var array
* Ex: $rowValues[$rowPos][$format][$entity][$field] = 'something';
* Ex: $rowValues[3]['text/plain']['contact']['display_name'] = 'something';
*/
public $rowValues;
/**
* A list of available tokens
* @var array
* Array(string $dottedName => array('entity'=>string, 'field'=>string, 'label'=>string)).
*/
protected $tokens = NULL;
/**
* A list of available tokens formatted for display
* @var array
* Array('{' . $dottedName . '}' => 'labelString')
*/
protected $listTokens = NULL;
protected $next = 0;
/**
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* @param array $context
*/
public function __construct($dispatcher, $context) {
$context['schema'] = isset($context['schema'])
? array_unique(array_merge($context['schema'], array_keys($context)))
: array_keys($context);
$this->dispatcher = $dispatcher;
$this->context = $context;
}
/**
* Register a string for which we'll need to merge in tokens.
*
* @param string $name
* Ex: 'subject', 'body_html'.
* @param string $value
* Ex: '<p>Hello {contact.name}</p>'.
* @param string $format
* Ex: 'text/html'.
* @return TokenProcessor
*/
public function addMessage($name, $value, $format) {
$tokens = [];
$this->visitTokens($value ?: '', function (?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use (&$tokens) {
$tokens[$entity][] = $field;
});
$this->messages[$name] = [
'string' => $value,
'format' => $format,
'tokens' => $tokens,
];
return $this;
}
/**
* Add a row of data.
*
* @param array|NULL $context
* Optionally, initialize the context for this row.
* Ex: ['contact_id' => 123].
* @return TokenRow
*/
public function addRow($context = NULL) {
$key = $this->next++;
$this->rowContexts[$key] = [];
$this->rowValues[$key] = [
'text/plain' => [],
'text/html' => [],
];
$row = new TokenRow($this, $key);
if ($context !== NULL) {
$row->context($context);
}
return $row;
}
/**
* Add several rows.
*
* @param array $contexts
* List of rows to add.
* Ex: [['contact_id'=>123], ['contact_id'=>456]]
* @return TokenRow[]
* List of row objects
*/
public function addRows($contexts) {
$rows = [];
foreach ($contexts as $context) {
$row = $this->addRow($context);
$rows[$row->tokenRow] = $row;
}
return $rows;
}
/**
* @param array $params
* Array with keys:
* - entity: string, e.g. "profile".
* - field: string, e.g. "viewUrl".
* - label: string, e.g. "Default Profile URL (View Mode)".
* @return TokenProcessor
*/
public function addToken($params) {
$key = $params['entity'] . '.' . $params['field'];
$this->tokens[$key] = $params;
return $this;
}
/**
* @param string $name
* @return array
* Keys:
* - string: Unprocessed message (eg "Hello, {display_name}.").
* - format: Media type (eg "text/plain").
*/
public function getMessage($name) {
return $this->messages[$name];
}
/**
* Get a list of all tokens used in registered messages.
*
* @return array
* The list of activated tokens, indexed by object/entity.
* Array(string $entityName => string[] $fieldNames)
*
* Ex: If a message says 'Hello {contact.first_name} {contact.last_name}!',
* then $result['contact'] would be ['first_name', 'last_name'].
*/
public function getMessageTokens() {
$tokens = [];
foreach ($this->messages as $message) {
$tokens = \CRM_Utils_Array::crmArrayMerge($tokens, $message['tokens']);
}
foreach (array_keys($tokens) as $e) {
$tokens[$e] = array_unique($tokens[$e]);
sort($tokens[$e]);
}
return $tokens;
}
/**
* Get a specific row (i.e. target or recipient).
*
* Ex: echo $p->getRow(2)->context['contact_id'];
* Ex: $p->getRow(3)->token('profile', 'viewUrl', 'http://example.com/profile?cid=3');
*
* @param int $key
* The row ID
* @return \Civi\Token\TokenRow
* The row is presented with a fluent, OOP facade.
* @see TokenRow
*/
public function getRow($key) {
return new TokenRow($this, $key);
}
/**
* Get the list of rows (i.e. targets/recipients to generate).
*
* @see TokenRow
* @return \Traversable<TokenRow>
* Each row is presented with a fluent, OOP facade.
*/
public function getRows() {
return new TokenRowIterator($this, new \ArrayIterator($this->rowContexts ?: []));
}
/**
* Get a list of all unique values for a given context field,
* whether defined at the processor or row level.
*
* @param string $field
* Ex: 'contactId'.
* @param string|NULL $subfield
* @return array
* Ex: [12, 34, 56].
*/
public function getContextValues($field, $subfield = NULL) {
$values = [];
if (isset($this->context[$field])) {
if ($subfield) {
if (isset($this->context[$field]->$subfield)) {
$values[] = $this->context[$field]->$subfield;
}
}
else {
$values[] = $this->context[$field];
}
}
foreach ($this->getRows() as $row) {
if (isset($row->context[$field])) {
if ($subfield) {
if (isset($row->context[$field]->$subfield)) {
$values[] = $row->context[$field]->$subfield;
}
}
else {
$values[] = $row->context[$field];
}
}
}
$values = array_unique($values);
return $values;
}
/**
* Get the list of available tokens.
*
* @return array
* Ex: $tokens['event'] = ['location', 'start_date', 'end_date'].
*/
public function getTokens() {
if ($this->tokens === NULL) {
$this->tokens = [];
$event = new TokenRegisterEvent($this, ['entity' => 'undefined']);
$this->dispatcher->dispatch('civi.token.list', $event);
}
return $this->tokens;
}
/**
* Get the list of available tokens, formatted for display
*
* @return array
* Ex: $tokens['{token.name}'] = "Token label"
*/
public function listTokens() {
if ($this->listTokens === NULL) {
$this->listTokens = [];
foreach ($this->getTokens() as $token => $values) {
$this->listTokens['{' . $token . '}'] = $values['label'];
}
}
return $this->listTokens;
}
/**
* Compute and store token values.
*/
public function evaluate() {
$event = new TokenValueEvent($this);
$this->dispatcher->dispatch('civi.token.eval', $event);
return $this;
}
/**
* Render a message.
*
* @param string $name
* The name previously registered with addMessage().
* @param TokenRow|int $row
* The object or ID for the row previously registered with addRow().
* @return string
* Fully rendered message, with tokens merged.
*/
public function render($name, $row) {
if (!is_object($row)) {
$row = $this->getRow($row);
}
$swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
$message = $this->getMessage($name);
$row->fill($message['format']);
$useSmarty = !empty($row->context['smarty']);
$tokens = $this->rowValues[$row->tokenRow][$message['format']];
$getToken = function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use ($tokens, $useSmarty, $row) {
if (isset($tokens[$entity][$field])) {
$v = $tokens[$entity][$field];
$v = $this->filterTokenValue($v, $modifier, $row);
if ($useSmarty) {
$v = \CRM_Utils_Token::tokenEscapeSmarty($v);
}
return $v;
}
return $fullToken;
};
$event = new TokenRenderEvent($this);
$event->message = $message;
$event->context = $row->context;
$event->row = $row;
$event->string = $this->visitTokens($message['string'] ?? '', $getToken);
$this->dispatcher->dispatch('civi.token.render', $event);
return $event->string;
}
private function visitTokens(string $expression, callable $callback): string {
// Regex examples: '{foo.bar}', '{foo.bar|whiz}', '{foo.bar|whiz:"bang"}', '{foo.bar|whiz:"bang":"bang"}'
// Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}'
// Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s.
$tokRegex = '([\w]+)\.([\w:\.]+)'; /* EX: 'foo.bar' in '{foo.bar|whiz:"bang":"bang"}' */
$argRegex = ':[\w": %\-_()\[\]\+/#@!,\.\?]*'; /* EX: ':"bang":"bang"' in '{foo.bar|whiz:"bang":"bang"}' */
// Debatable: Maybe relax to this: $argRegex = ':[^{}\n]*'; /* EX: ':"bang":"bang"' in '{foo.bar|whiz:"bang":"bang"}' */
$filterRegex = "(\w+(?:$argRegex)?)"; /* EX: 'whiz:"bang"' in '{foo.bar|whiz:"bang"' */
return preg_replace_callback(";\{$tokRegex(?:\|$filterRegex)?\};", function($m) use ($callback) {
$filterParts = NULL;
if (isset($m[3])) {
$filterParts = [];
$enqueue = function($m) use (&$filterParts) {
$filterParts[] = $m[1];
return '';
};
$unmatched = preg_replace_callback_array([
'/^(\w+)/' => $enqueue,
'/:"([^"]+)"/' => $enqueue,
], $m[3]);
if ($unmatched) {
throw new \CRM_Core_Exception("Malformed token parameters (" . $m[0] . ")");
}
}
return $callback($m[0] ?? NULL, $m[1] ?? NULL, $m[2] ?? NULL, $filterParts);
}, $expression);
}
/**
* Given a token value, run it through any filters.
*
* @param mixed $value
* Raw token value (e.g. from `$row->tokens['foo']['bar']`).
* @param array|null $filter
* @param TokenRow $row
* The current target/row.
* @return string
* @throws \CRM_Core_Exception
*/
private function filterTokenValue($value, ?array $filter, TokenRow $row) {
// KISS demonstration. This should change... e.g. provide a filter-registry or reuse Smarty's registry...
if ($value instanceof \DateTime && $filter === NULL) {
$filter = ['crmDate'];
}
switch ($filter[0]) {
case NULL:
return $value;
case 'upper':
return mb_strtoupper($value);
case 'lower':
return mb_strtolower($value);
case 'crmDate':
if ($value instanceof \DateTime) {
// @todo cludgey.
require_once 'CRM/Core/Smarty/plugins/modifier.crmDate.php';
return \smarty_modifier_crmDate($value->format('Y-m-d H:i:s'), $filter[1] ?? NULL);
}
default:
throw new \CRM_Core_Exception("Invalid token filter: $filter");
}
}
}
class TokenRowIterator extends \IteratorIterator {
/**
* @var \Civi\Token\TokenProcessor
*/
protected $tokenProcessor;
/**
* @param TokenProcessor $tokenProcessor
* @param \Traversable $iterator
*/
public function __construct(TokenProcessor $tokenProcessor, Traversable $iterator) {
// TODO: Change the autogenerated stub
parent::__construct($iterator);
$this->tokenProcessor = $tokenProcessor;
}
public function current() {
return new TokenRow($this->tokenProcessor, parent::key());
}
}