*/
use Civi\Token\AbstractTokenSubscriber;
+use Civi\Token\Event\TokenRegisterEvent;
use Civi\Token\Event\TokenValueEvent;
use Civi\Token\TokenRow;
use Civi\ActionSchedule\Event\MailingQueryEvent;
use Civi\Token\TokenProcessor;
+use Brick\Money\Money;
/**
* Class CRM_Core_EntityTokens
*/
class CRM_Core_EntityTokens extends AbstractTokenSubscriber {
+ /**
+ * Metadata about all tokens.
+ *
+ * @var array
+ */
+ protected $tokensMetadata = [];
/**
* @var array
*/
protected $prefetch = [];
+ /**
+ * Should permissions be checked when loading tokens.
+ *
+ * @var bool
+ */
+ protected $checkPermissions = FALSE;
+
+ /**
+ * Register the declared tokens.
+ *
+ * @param \Civi\Token\Event\TokenRegisterEvent $e
+ * The registration event. Add new tokens using register().
+ */
+ public function registerTokens(TokenRegisterEvent $e) {
+ if (!$this->checkActive($e->getTokenProcessor())) {
+ return;
+ }
+ foreach ($this->getTokenMetadata() as $tokenName => $field) {
+ if ($field['audience'] === 'user') {
+ $e->register([
+ 'entity' => $this->entity,
+ 'field' => $tokenName,
+ 'label' => $field['title'],
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Get the metadata about the available tokens
+ *
+ * @return array
+ */
+ protected function getTokenMetadata(): array {
+ $cacheKey = $this->getCacheKey();
+ if (!Civi::cache('metadata')->has($cacheKey)) {
+ $tokensMetadata = $this->getBespokeTokens();
+ foreach ($this->getFieldMetadata() as $field) {
+ $this->addFieldToTokenMetadata($tokensMetadata, $field, $this->getExposedFields());
+ }
+ foreach ($this->getHiddenTokens() as $name) {
+ $tokensMetadata[$name]['audience'] = 'hidden';
+ }
+ Civi::cache('metadata')->set($cacheKey, $tokensMetadata);
+ }
+ return Civi::cache('metadata')->get($cacheKey);
+ }
+
/**
* @inheritDoc
* @throws \CRM_Core_Exception
return $row->tokens($entity, $field, $this->getPseudoValue($split[0], $split[1], $this->getFieldValue($row, $split[0])));
}
if ($this->isCustomField($field)) {
+ $prefetchedValue = $this->getCustomFieldValue($this->getFieldValue($row, 'id'), $field);
+ if ($prefetchedValue) {
+ return $row->format('text/html')->tokens($entity, $field, $prefetchedValue);
+ }
return $row->customToken($entity, \CRM_Core_BAO_CustomField::getKeyID($field), $this->getFieldValue($row, 'id'));
}
if ($this->isMoneyField($field)) {
+ $currency = $this->getCurrency($row);
+ if (empty($fieldValue) && !is_numeric($fieldValue)) {
+ $fieldValue = 0;
+ }
+ if (!$currency) {
+ // too hard basket for now - just do what we always did.
+ return $row->format('text/plain')->tokens($entity, $field,
+ \CRM_Utils_Money::format($fieldValue, $currency));
+ }
return $row->format('text/plain')->tokens($entity, $field,
- \CRM_Utils_Money::format($fieldValue, $this->getCurrency($row)));
+ Money::of($fieldValue, $currency));
+
}
if ($this->isDateField($field)) {
try {
return $row->format('text/plain')
- ->tokens($entity, $field, new DateTime($fieldValue));
+ ->tokens($entity, $field, ($fieldValue ? new DateTime($fieldValue) : $fieldValue));
}
catch (Exception $e) {
Civi::log()->info('invalid date token');
return CRM_Core_DAO_AllCoreTables::getTableForEntityName($this->getApiEntityName());
}
- /**
- * Get the relevant bao name.
- */
- public function getBAOName(): string {
- return CRM_Core_DAO_AllCoreTables::getFullName($this->getApiEntityName());
- }
-
/**
* Get an array of fields to be requested.
*
+ * @todo this function should look up tokenMetadata that
+ * is already loaded.
+ *
* @return string[]
*/
- public function getReturnFields(): array {
+ protected function getReturnFields(): array {
return array_keys($this->getBasicTokens());
}
- /**
- * Get all the tokens supported by this processor.
- *
- * @return array|string[]
- * @throws \API_Exception
- */
- protected function getAllTokens(): array {
- $basicTokens = $this->getBasicTokens();
- foreach (array_keys($basicTokens) as $fieldName) {
- // The goal is to be able to render more complete tokens
- // (eg. actual booleans, field names, raw ids) for a more
- // advanced audiences - ie those using conditionals
- // and to specify that audience in the api that retrieves.
- // But, for now, let's not advertise, given that most of these fields
- // aren't really needed even once...
- if ($this->isBooleanField($fieldName)) {
- unset($basicTokens[$fieldName]);
- }
- }
- return array_merge($basicTokens, $this->getPseudoTokens(), CRM_Utils_Token::getCustomFieldTokens($this->getApiEntityName()));
- }
-
/**
* Is the given field a boolean field.
*
*
* @return bool
*/
- public function isBooleanField(string $fieldName): bool {
- return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean';
+ protected function isBooleanField(string $fieldName): bool {
+ return $this->getMetadataForField($fieldName)['data_type'] === 'Boolean';
}
/**
*
* @return bool
*/
- public function isDateField(string $fieldName): bool {
- return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE);
+ protected function isDateField(string $fieldName): bool {
+ return in_array($this->getMetadataForField($fieldName)['data_type'], ['Timestamp', 'Date'], TRUE);
}
/**
*
* @return bool
*/
- public function isPseudoField(string $fieldName): bool {
+ protected function isPseudoField(string $fieldName): bool {
return strpos($fieldName, ':') !== FALSE;
}
*
* @return bool
*/
- public function isCustomField(string $fieldName) : bool {
+ protected function isCustomField(string $fieldName) : bool {
return (bool) \CRM_Core_BAO_CustomField::getKeyID($fieldName);
}
*
* @return bool
*/
- public function isMoneyField(string $fieldName): bool {
- return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Money';
+ protected function isMoneyField(string $fieldName): bool {
+ return $this->getMetadataForField($fieldName)['data_type'] === 'Money';
}
/**
}
/**
- * Get pseudoTokens - it tokens that reflect the name or label of a pseudoconstant.
- *
- * @internal - this function will likely be made protected soon.
- *
- * @return array
- */
- public function getPseudoTokens(): array {
- $return = [];
- foreach (array_keys($this->getBasicTokens()) as $fieldName) {
- if ($this->isAddPseudoTokens($fieldName)) {
- $fieldLabel = $this->fieldMetadata[$fieldName]['input_attrs']['label'] ?? $this->fieldMetadata[$fieldName]['label'];
- $return[$fieldName . ':label'] = $fieldLabel;
- $return[$fieldName . ':name'] = ts('Machine name') . ': ' . $fieldLabel;
- }
- if ($this->isBooleanField($fieldName)) {
- $return[$fieldName . ':label'] = $this->getFieldMetadata()[$fieldName]['title'];
- }
- }
- return $return;
- }
-
- /**
- * Is this a field we should add pseudo-tokens to?
- *
- * Pseudo-tokens allow access to name and label fields - e.g
- *
- * {contribution.contribution_status_id:name} might resolve to 'Completed'
- *
- * @param string $fieldName
+ * Get any tokens with custom calculation.
*/
- public function isAddPseudoTokens($fieldName): bool {
- if ($fieldName === 'currency') {
- // 'currency' is manually added to the skip list as an anomaly.
- // name & label aren't that suitable for 'currency' (symbol, which
- // possibly maps to 'abbr' would be) and we can't gather that
- // from the metadata as yet.
- return FALSE;
- }
- if ($this->getFieldMetadata()[$fieldName]['type'] === 'Custom') {
- // If we remove this early return then we get that extra nuanced goodness
- // and support for the more portable v4 style field names
- // on custom fields - where labels or names can be returned.
- // At present the gap is that the metadata for the label is not accessed
- // and tests failed on the enotice and we don't have a clear plan about
- // v4 style custom tokens - but medium term this IF will probably go.
- return FALSE;
- }
- return (bool) ($this->getFieldMetadata()[$fieldName]['options'] || !empty($this->getFieldMetadata()[$fieldName]['suffixes']));
+ protected function getBespokeTokens(): array {
+ return [];
}
/**
*
* @internal function will likely be protected soon.
*/
- public function getPseudoValue(string $realField, string $pseudoKey, $fieldValue): string {
+ protected function getPseudoValue(string $realField, string $pseudoKey, $fieldValue): string {
+ $bao = CRM_Core_DAO_AllCoreTables::getFullName($this->getMetadataForField($realField)['entity']);
if ($pseudoKey === 'name') {
- $fieldValue = (string) CRM_Core_PseudoConstant::getName($this->getBAOName(), $realField, $fieldValue);
+ $fieldValue = (string) CRM_Core_PseudoConstant::getName($bao, $realField, $fieldValue);
}
if ($pseudoKey === 'label') {
- $fieldValue = (string) CRM_Core_PseudoConstant::getLabel($this->getBAOName(), $realField, $fieldValue);
+ $fieldValue = (string) CRM_Core_PseudoConstant::getLabel($bao, $realField, $fieldValue);
+ }
+ if ($pseudoKey === 'abbr' && $realField === 'state_province_id') {
+ // hack alert - currently only supported for state.
+ $fieldValue = (string) CRM_Core_PseudoConstant::stateProvinceAbbreviation($fieldValue);
}
return (string) $fieldValue;
}
return $row->context[$entityName][$field];
}
- $actionSearchResult = $row->context['actionSearchResult'];
- $aliasedField = $this->getEntityAlias() . $field;
- if (isset($actionSearchResult->{$aliasedField})) {
- return $actionSearchResult->{$aliasedField};
- }
$entityID = $row->context[$this->getEntityIDField()];
+ if ($field === 'id') {
+ return $entityID;
+ }
return $this->prefetch[$entityID][$field] ?? '';
}
* Class constructor.
*/
public function __construct() {
- $tokens = $this->getAllTokens();
- parent::__construct($this->getEntityName(), $tokens);
+ parent::__construct($this->getEntityName(), []);
}
/**
if ($e->mapping->getEntity() !== $this->getExtendableTableName()) {
return;
}
- foreach ($this->getReturnFields() as $token) {
- $e->query->select('e.' . $token . ' AS ' . $this->getEntityAlias() . $token);
- }
+ $e->query->select('e.id AS tokenContext_' . $this->getEntityIDField());
}
/**
- * Get tokens supporting the syntax we are migrating to.
+ * Get tokens to be suppressed from the widget.
*
- * In general these are tokens that were not previously supported
- * so we can add them in the preferred way or that we have
- * undertaken some, as yet to be written, db update.
- *
- * See https://lab.civicrm.org/dev/core/-/issues/2650
+ * Note this is expected to be an interim function. Now we are no
+ * longer working around the parent function we can just define them once...
+ * with metadata, in a future refactor.
+ */
+ protected function getHiddenTokens(): array {
+ return [];
+ }
+
+ /**
+ * @todo remove this function & use the metadata that is loaded.
*
* @return string[]
* @throws \API_Exception
*/
- public function getBasicTokens(): array {
+ protected function getBasicTokens(): array {
$return = [];
foreach ($this->getExposedFields() as $fieldName) {
// Custom fields are still added v3 style - we want to keep v4 naming 'unpoluted'
* @return string[]
*
*/
- public function getExposedFields(): array {
+ protected function getExposedFields(): array {
$return = [];
foreach ($this->getFieldMetadata() as $field) {
if (!in_array($field['name'], $this->getSkippedFields(), TRUE)) {
*
* @return string[]
*/
- public function getSkippedFields(): array {
+ protected function getSkippedFields(): array {
// tags is offered in 'case' & is one of the only fields that is
// 'not a real field' offered up by case - seems like an oddity
// we should skip at the top level for now.
- $fields = ['contact_id', 'tags'];
+ $fields = ['tags'];
if (!CRM_Campaign_BAO_Campaign::isCampaignEnable()) {
$fields[] = 'campaign_id';
}
return CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($this->getApiEntityName());
}
- public function getEntityIDField(): string {
+ protected function getEntityIDField(): string {
return $this->getEntityName() . 'Id';
}
return $result;
}
- public function getCurrencyFieldName() {
+ protected function getCurrencyFieldName() {
return [];
}
*
* @return string
*/
- public function getCurrency($row): string {
+ protected function getCurrency($row): string {
if (!empty($this->getCurrencyFieldName())) {
return $this->getFieldValue($row, $this->getCurrencyFieldName()[0]);
}
* @throws \API_Exception
*/
public function getPrefetchFields(TokenValueEvent $e): array {
- $allTokens = array_keys($this->getAllTokens());
+ $allTokens = array_keys($this->getTokenMetadata());
$requiredFields = array_intersect($this->getActiveTokens($e), $allTokens);
if (empty($requiredFields)) {
return [];
*
* @return array
*/
- public function getDependencies(): array {
+ protected function getDependencies(): array {
+ return [];
+ }
+
+ /**
+ * Get the apiv4 style custom field name.
+ *
+ * @param int $id
+ *
+ * @return string
+ *
+ * @throws \CRM_Core_Exception
+ */
+ protected function getCustomFieldName(int $id): string {
+ foreach ($this->getTokenMetadata() as $key => $field) {
+ if (($field['custom_field_id'] ?? NULL) === $id) {
+ return $key;
+ }
+ }
+ throw new CRM_Core_Exception(
+ "A custom field with the ID {$id} does not exist"
+ );
+ }
+
+ /**
+ * @param $entityID
+ * @param string $field eg. 'custom_1'
+ *
+ * @return array|string|void|null $mixed
+ *
+ * @throws \CRM_Core_Exception
+ */
+ protected function getCustomFieldValue($entityID, string $field) {
+ $id = str_replace('custom_', '', $field);
+ try {
+ $value = $this->prefetch[$entityID][$this->getCustomFieldName($id)] ?? '';
+ if ($value !== NULL) {
+ return CRM_Core_BAO_CustomField::displayValue($value, $id);
+ }
+ }
+ catch (CRM_Core_Exception $exception) {
+ return NULL;
+ }
+ }
+
+ /**
+ * Get the metadata for the field.
+ *
+ * @param string $fieldName
+ *
+ * @return array
+ */
+ protected function getMetadataForField($fieldName): array {
+ if (isset($this->getTokenMetadata()[$fieldName])) {
+ return $this->getTokenMetadata()[$fieldName];
+ }
+ if (isset($this->getTokenMappingsForRelatedEntities()[$fieldName])) {
+ return $this->getTokenMetadata()[$this->getTokenMappingsForRelatedEntities()[$fieldName]];
+ }
+ return $this->getTokenMetadata()[$this->getDeprecatedTokens()[$fieldName]] ?? [];
+ }
+
+ /**
+ * Get token mappings for related entities - specifically the contact entity.
+ *
+ * This function exists to help manage the way contact tokens is structured
+ * of an query-object style result set that needs to be mapped to apiv4.
+ *
+ * The end goal is likely to be to advertised tokens that better map to api
+ * v4 and deprecate the existing ones but that is a long-term migration.
+ *
+ * @return array
+ */
+ protected function getTokenMappingsForRelatedEntities(): array {
return [];
}
+ /**
+ * Get array of deprecated tokens and the new token they map to.
+ *
+ * @return array
+ */
+ protected function getDeprecatedTokens(): array {
+ return [];
+ }
+
+ /**
+ * Get any overrides for token metadata.
+ *
+ * This is most obviously used for setting the audience, which
+ * will affect widget-presence.
+ *
+ * @return \string[][]
+ */
+ protected function getTokenMetadataOverrides(): array {
+ return [];
+ }
+
+ /**
+ * To handle variable tokens, override this function and return the active tokens.
+ *
+ * @param \Civi\Token\Event\TokenValueEvent $e
+ *
+ * @return mixed
+ */
+ public function getActiveTokens(TokenValueEvent $e) {
+ $messageTokens = $e->getTokenProcessor()->getMessageTokens();
+ if (!isset($messageTokens[$this->entity])) {
+ return FALSE;
+ }
+ return array_intersect($messageTokens[$this->entity], array_keys($this->getTokenMetadata()));
+ }
+
+ /**
+ * Add the token to the metadata based on the field spec.
+ *
+ * @param array $tokensMetadata
+ * @param array $field
+ * @param array $exposedFields
+ * @param string $prefix
+ */
+ protected function addFieldToTokenMetadata(array &$tokensMetadata, array $field, array $exposedFields, string $prefix = ''): void {
+ if ($field['type'] !== 'Custom' && !in_array($field['name'], $exposedFields, TRUE)) {
+ return;
+ }
+ $field['audience'] = 'user';
+ if ($field['name'] === 'contact_id') {
+ // Since {contact.id} is almost always present don't confuse users
+ // by also adding (e.g {participant.contact_id)
+ $field['audience'] = 'sysadmin';
+ }
+ if (!empty($this->getTokenMetadataOverrides()[$field['name']])) {
+ $field = array_merge($field, $this->getTokenMetadataOverrides()[$field['name']]);
+ }
+ if ($field['type'] === 'Custom') {
+ // Convert to apiv3 style for now. Later we can add v4 with
+ // portable naming & support for labels/ dates etc so let's leave
+ // the space open for that.
+ // Not the existing quickform widget has handling for the custom field
+ // format based on the title using this syntax.
+ $parts = explode(': ', $field['label']);
+ $field['title'] = "{$parts[1]} :: {$parts[0]}";
+ $tokenName = 'custom_' . $field['custom_field_id'];
+ $tokensMetadata[$tokenName] = $field;
+ return;
+ }
+ $tokenName = $prefix ? ($prefix . '.' . $field['name']) : $field['name'];
+ if (in_array($field['name'], $exposedFields, TRUE)) {
+ if (
+ ($field['options'] || !empty($field['suffixes']))
+ // At the time of writing currency didn't have a label option - this may have changed.
+ && !in_array($field['name'], $this->getCurrencyFieldName(), TRUE)
+ ) {
+ $tokensMetadata[$tokenName . ':label'] = $tokensMetadata[$tokenName . ':name'] = $field;
+ $fieldLabel = $field['input_attrs']['label'] ?? $field['label'];
+ $tokensMetadata[$tokenName . ':label']['name'] = $field['name'] . ':label';
+ $tokensMetadata[$tokenName . ':name']['name'] = $field['name'] . ':name';
+ $tokensMetadata[$tokenName . ':name']['audience'] = 'sysadmin';
+ $tokensMetadata[$tokenName . ':label']['title'] = $fieldLabel;
+ $tokensMetadata[$tokenName . ':name']['title'] = ts('Machine name') . ': ' . $fieldLabel;
+ $field['audience'] = 'sysadmin';
+ }
+ if ($field['data_type'] === 'Boolean') {
+ $tokensMetadata[$tokenName . ':label'] = $field;
+ $tokensMetadata[$tokenName . ':label']['name'] = $field['name'] . ':label';
+ $field['audience'] = 'sysadmin';
+ }
+ $tokensMetadata[$tokenName] = $field;
+ }
+ }
+
+ /**
+ * Get a cache key appropriate to the current usage.
+ *
+ * @return string
+ */
+ protected function getCacheKey(): string {
+ $cacheKey = __CLASS__ . 'token_metadata' . $this->getApiEntityName() . CRM_Core_Config::domainID() . '_' . CRM_Core_I18n::getLocale();
+ if ($this->checkPermissions) {
+ $cacheKey .= '__' . CRM_Core_Session::getLoggedInContactID();
+ }
+ return $cacheKey;
+ }
+
}