Skip to content

Commit

Permalink
Merge pull request #21531 from eileenmcnaughton/now
Browse files Browse the repository at this point in the history
Add {domain.now}, supporting |crmDate
  • Loading branch information
colemanw authored Sep 22, 2021
2 parents a6d36b5 + 2d2d316 commit bd500f7
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 18 deletions.
6 changes: 6 additions & 0 deletions CRM/Core/DomainTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public function getDomainTokens(): array {
'email' => ts('Domain (organization) email'),
'id' => ts('Domain ID'),
'description' => ts('Domain Description'),
'now' => ts('Current time/date'),
];
}

Expand All @@ -55,6 +56,11 @@ public function getDomainTokens(): array {
* @throws \CRM_Core_Exception
*/
public function evaluateToken(TokenRow $row, $entity, $field, $prefetch = NULL): void {
if ($field === 'now') {
$nowObj = (new \DateTime())->setTimestamp(\CRM_Utils_Time::time());
$row->format('text/html')->tokens($entity, $field, $nowObj);
return;
}
$row->format('text/html')->tokens($entity, $field, self::getDomainTokenValues()[$field]);
$row->format('text/plain')->tokens($entity, $field, self::getDomainTokenValues(NULL, FALSE)[$field]);
}
Expand Down
68 changes: 52 additions & 16 deletions Civi/Token/TokenProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,14 @@ public function __construct($dispatcher, $context) {
* @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' => \CRM_Utils_Token::getTokens($value),
'tokens' => $tokens,
];
return $this;
}
Expand Down Expand Up @@ -361,49 +365,74 @@ public function render($name, $row) {
$useSmarty = !empty($row->context['smarty']);

$tokens = $this->rowValues[$row->tokenRow][$message['format']];
$getToken = function($m) use ($tokens, $useSmarty, $row) {
[$full, $entity, $field] = $m;
$getToken = function(?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use ($tokens, $useSmarty, $row) {
if (isset($tokens[$entity][$field])) {
$v = $tokens[$entity][$field];
if (isset($m[3])) {
$v = $this->filterTokenValue($v, $m[3], $row);
}
$v = $this->filterTokenValue($v, $modifier, $row);
if ($useSmarty) {
$v = \CRM_Utils_Token::tokenEscapeSmarty($v);
}
return $v;
}
return $full;
return $fullToken;
};

$event = new TokenRenderEvent($this);
$event->message = $message;
$event->context = $row->context;
$event->row = $row;
// Regex examples: '{foo.bar}', '{foo.bar|whiz}'
// 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:\.]+)';
$filterRegex = '(\w+)';
$event->string = preg_replace_callback(";\{$tokRegex(?:\|$filterRegex)?\};", $getToken, $message['string']);
$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 string $filter
* @param array|null $filter
* @param TokenRow $row
* The current target/row.
* @return string
* @throws \CRM_Core_Exception
*/
private function filterTokenValue($value, $filter, TokenRow $row) {
private function filterTokenValue($value, ?array $filter, TokenRow $row) {
// KISS demonstration. This should change... e.g. provide a filter-registry or reuse Smarty's registry...
switch ($filter) {

if ($value instanceof \DateTime && $filter === NULL) {
$filter = ['crmDate'];
}

switch ($filter[0]) {
case NULL:
return $value;

Expand All @@ -413,6 +442,13 @@ private function filterTokenValue($value, $filter, TokenRow $row) {
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");
}
Expand Down
7 changes: 5 additions & 2 deletions Civi/Token/TokenRow.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,18 @@ public function fill($format = NULL) {
// HTML => Plain.
foreach ($htmlTokens as $entity => $values) {
foreach ($values as $field => $value) {
if (!$value instanceof \DateTime) {
$value = html_entity_decode(strip_tags($value));
}
if (!isset($textTokens[$entity][$field])) {
$textTokens[$entity][$field] = html_entity_decode(strip_tags($value));
$textTokens[$entity][$field] = $value;
}
}
}
break;

default:
throw new \RuntimeException("Invalid format");
throw new \RuntimeException('Invalid format');
}

return $this;
Expand Down
37 changes: 37 additions & 0 deletions tests/phpunit/CRM/Core/TokenSmartyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,35 @@ public function testMixedData() {
['extra' => ['foo' => 'foobar']]
);
$this->assertEquals('First name is Bob. ExtraFoo is foobar.', $rendered['msg_subject']);

try {
$modifiers = [
'|crmDate:"shortdate"' => '02/01/2020',
'|crmDate:"%B %Y"' => 'February 2020',
'|crmDate' => 'February 1st, 2020 3:04 AM',
];
foreach ($modifiers as $modifier => $expected) {
CRM_Utils_Time::setTime('2020-02-01 03:04:05');
$rendered = CRM_Core_TokenSmarty::render(
['msg_subject' => "Now is the token, {domain.now$modifier}! No, now is the smarty-pants, {\$extra.now$modifier}!"],
['contactId' => $this->contactId],
['extra' => ['now' => '2020-02-01 03:04:05']]
);
$this->assertEquals("Now is the token, $expected! No, now is the smarty-pants, $expected!", $rendered['msg_subject']);
}
}
finally {
\CRM_Utils_Time::resetTime();
}
}

/**
* A template which uses token-data as part of a Smarty expression.
*/
public function testTokenInSmarty() {
\CRM_Utils_Time::setTime('2022-04-08 16:32:04');
$resetTime = \CRM_Utils_AutoClean::with(['CRM_Utils_Time', 'resetTime']);

$rendered = CRM_Core_TokenSmarty::render(
['msg_html' => '<p>{assign var="greeting" value="{contact.email_greeting}"}Greeting: {$greeting}!</p>'],
['contactId' => $this->contactId],
Expand All @@ -51,6 +74,20 @@ public function testTokenInSmarty() {
[]
);
$this->assertEquals('<p>Yes CID</p>', $rendered['msg_html']);

$rendered = CRM_Core_TokenSmarty::render(
['msg_html' => '<p>{assign var="greeting" value="hey yo {contact.first_name|upper} {contact.last_name|upper} circa {domain.now|crmDate:"%m/%Y"}"}My Greeting: {$greeting}!</p>'],
['contactId' => $this->contactId],
[]
);
$this->assertEquals('<p>My Greeting: hey yo BOB ROBERTS circa 04/2022!</p>', $rendered['msg_html']);

$rendered = CRM_Core_TokenSmarty::render(
['msg_html' => '<p>{assign var="greeting" value="hey yo {contact.first_name} {contact.last_name|upper} circa {domain.now|crmDate:"shortdate"}"}My Greeting: {$greeting|capitalize}!</p>'],
['contactId' => $this->contactId],
[]
);
$this->assertEquals('<p>My Greeting: Hey Yo Bob ROBERTS Circa 04/08/2022!</p>', $rendered['msg_html']);
}

/**
Expand Down
42 changes: 42 additions & 0 deletions tests/phpunit/CRM/Utils/TokenConsistencyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,50 @@ public function testDomainTokenConsistency(): void {
]);
$tokens['{domain.id}'] = 'Domain ID';
$tokens['{domain.description}'] = 'Domain Description';
$tokens['{domain.now}'] = 'Current time/date';
$this->assertEquals($tokens, $tokenProcessor->listTokens());
}

/**
* @throws \API_Exception
* @throws \CRM_Core_Exception
*/
public function testDomainNow(): void {
putenv('TIME_FUNC=frozen');
CRM_Utils_Time::setTime('2021-09-18 23:58:00');
$modifiers = [
'shortdate' => '09/18/2021',
'%B %Y' => 'September 2021',
];
foreach ($modifiers as $filter => $expected) {
$resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([
'messageTemplate' => [
'msg_text' => '{domain.now|crmDate:"' . $filter . '"}',
],
])['text'];
$this->assertEquals($expected, $resolved);
}
$resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([
'messageTemplate' => [
'msg_text' => '{domain.now}',
],
])['text'];
$this->assertEquals('September 18th, 2021 11:58 PM', $resolved);

// This example is malformed - no quotes
try {
$resolved = CRM_Core_BAO_MessageTemplate::renderTemplate([
'messageTemplate' => [
'msg_text' => '{domain.now|crmDate:shortdate}',
],
])['text'];
$this->fail("Expected unquoted parameter to fail");
}
catch (\CRM_Core_Exception $e) {
$this->assertRegExp(';Malformed token param;', $e->getMessage());
}
}

/**
* Get declared participant tokens.
*
Expand All @@ -555,6 +596,7 @@ public function getDomainTokens(): array {
'{domain.email}' => 'Domain (organization) email',
'{domain.id}' => ts('Domain ID'),
'{domain.description}' => ts('Domain Description'),
'{domain.now}' => 'Current time/date',
];
}

Expand Down
31 changes: 31 additions & 0 deletions tests/phpunit/Civi/Token/TokenProcessorTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php
namespace Civi\Token;

use Civi\Test\Invasive;
use Civi\Token\Event\TokenRegisterEvent;
use Civi\Token\Event\TokenValueEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
Expand Down Expand Up @@ -30,6 +31,36 @@ protected function setUp(): void {
];
}

/**
* The visitTokens() method is internal - but it is important basis for other methods.
* Specifically, it parses all token expressions and invokes a callback for each.
*
* Ensure these callbacks get the expected data (with various quirky notations).
*/
public function testVisitTokens() {
$p = new TokenProcessor($this->dispatcher, [
'controller' => __CLASS__,
]);
$examples = [
'{foo.bar}' => ['foo', 'bar', NULL],
'{foo.bar|whiz}' => ['foo', 'bar', ['whiz']],
'{foo.bar|whiz:"bang"}' => ['foo', 'bar', ['whiz', 'bang']],
'{love.shack|place:"bang":"b@ng, on +he/([do0r])?!"}' => ['love', 'shack', ['place', 'bang', 'b@ng, on +he/([do0r])?!']],
];
foreach ($examples as $input => $expected) {
array_unshift($expected, $input);
$log = [];
Invasive::call([$p, 'visitTokens'], [
$input,
function (?string $fullToken, ?string $entity, ?string $field, ?array $modifier) use (&$log) {
$log[] = [$fullToken, $entity, $field, $modifier];
},
]);
$this->assertEquals(1, count($log), "Should receive one callback on expression: $input");
$this->assertEquals($expected, $log[0]);
}
}

/**
* Test that a row can be added via "addRow(array $context)".
*/
Expand Down

0 comments on commit bd500f7

Please sign in to comment.