3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
12 use Civi\Api4\CustomField
;
13 use Civi\Api4\UserJob
;
18 * @copyright CiviCRM LLC https://civicrm.org/licensing
20 abstract class CRM_Import_Parser
{
24 const MAX_WARNINGS
= 25, DEFAULT_TIMEOUT
= 30;
29 const VALID
= 1, WARNING
= 2, ERROR
= 4, CONFLICT
= 8, STOP
= 16, DUPLICATE
= 32, MULTIPLE_DUPE
= 64, NO_MATCH
= 128, UNPARSED_ADDRESS_WARNING
= 256;
34 const MODE_MAPFIELD
= 1, MODE_PREVIEW
= 2, MODE_SUMMARY
= 4, MODE_IMPORT
= 8;
37 * Codes for duplicate record handling
39 const DUPLICATE_SKIP
= 1, DUPLICATE_UPDATE
= 4, DUPLICATE_FILL
= 8, DUPLICATE_NOCHECK
= 16;
44 const CONTACT_INDIVIDUAL
= 1, CONTACT_HOUSEHOLD
= 2, CONTACT_ORGANIZATION
= 4;
49 * This is the primary key of the civicrm_user_job table which is used to
57 * The user job in use.
64 * Potentially ambiguous options.
66 * For example 'UT' is a state in more than one country.
70 protected $ambiguousOptions = [];
73 * States to country mapping.
77 protected $statesByCountry = [];
82 public function getUserJobID(): ?
int {
83 return $this->userJobID
;
87 * Ids of contacts created this iteration.
91 protected $createdContacts = [];
96 * @param int $userJobID
100 public function setUserJobID(int $userJobID): self
{
101 $this->userJobID
= $userJobID;
106 * Countries that the site is restricted to
110 private $availableCountries;
115 * API call to retrieve the userJob row.
119 * @throws \API_Exception
121 protected function getUserJob(): array {
122 if (empty($this->userJob
)) {
123 $this->userJob
= UserJob
::get()
124 ->addWhere('id', '=', $this->getUserJobID())
128 return $this->userJob
;
132 * Get the relevant datasource object.
134 * @return \CRM_Import_DataSource|null
136 * @throws \API_Exception
138 protected function getDataSourceObject(): ?CRM_Import_DataSource
{
139 $className = $this->getSubmittedValue('dataSource');
141 /* @var CRM_Import_DataSource $dataSource */
142 return new $className($this->getUserJobID());
148 * Get the submitted value, as stored on the user job.
150 * @param string $fieldName
154 * @noinspection PhpDocMissingThrowsInspection
155 * @noinspection PhpUnhandledExceptionInspection
157 protected function getSubmittedValue(string $fieldName) {
158 return $this->getUserJob()['metadata']['submitted_values'][$fieldName];
162 * Has the import completed.
166 * @throws \API_Exception
167 * @throws \CRM_Core_Exception
169 public function isComplete() :bool {
170 return $this->getDataSourceObject()->isCompleted();
174 * Get configured contact type.
176 * @throws \API_Exception
178 protected function getContactType() {
179 if (!$this->_contactType
) {
180 $contactTypeMapping = [
181 CRM_Import_Parser
::CONTACT_INDIVIDUAL
=> 'Individual',
182 CRM_Import_Parser
::CONTACT_HOUSEHOLD
=> 'Household',
183 CRM_Import_Parser
::CONTACT_ORGANIZATION
=> 'Organization',
185 $this->_contactType
= $contactTypeMapping[$this->getSubmittedValue('contactType')];
187 return $this->_contactType
;
191 * Get configured contact type.
193 * @return string|null
195 * @throws \API_Exception
197 public function getContactSubType() {
198 if (!$this->_contactSubType
) {
199 $this->_contactSubType
= $this->getSubmittedValue('contactSubType');
201 return $this->_contactSubType
;
205 * Total number of non empty lines
208 protected $_totalCount;
211 * Running total number of valid lines
214 protected $_validCount;
217 * Running total number of invalid rows
220 protected $_invalidRowCount;
223 * Maximum number of non-empty/comment lines to process
227 protected $_maxLinesToProcess;
230 * Array of error lines, bounded by MAX_ERROR
236 * Total number of duplicate (from database) lines
239 protected $_duplicateCount;
242 * Array of duplicate lines
245 protected $_duplicates;
248 * Maximum number of warnings to store
251 protected $_maxWarningCount = self
::MAX_WARNINGS
;
254 * Array of warning lines, bounded by MAX_WARNING
257 protected $_warnings;
260 * Array of all the fields that could potentially be part
261 * of this import process
267 * Metadata for all available fields, keyed by unique name.
269 * This is intended to supercede $_fields which uses a special sauce format which
270 * importableFieldsMetadata uses the standard getfields type format.
274 protected $importableFieldsMetadata = [];
277 * Get metadata for all importable fields in std getfields style format.
281 public function getImportableFieldsMetadata(): array {
282 return $this->importableFieldsMetadata
;
286 * Set metadata for all importable fields in std getfields style format.
288 * @param array $importableFieldsMetadata
290 public function setImportableFieldsMetadata(array $importableFieldsMetadata): void
{
291 $this->importableFieldsMetadata
= $importableFieldsMetadata;
295 * Gets the fields available for importing in a key-name, title format.
298 * eg. ['first_name' => 'First Name'.....]
300 * @throws \API_Exception
302 * @todo - we are constructing the metadata before we
303 * have set the contact type so we re-do it here.
305 * Once we have cleaned up the way the mapper is handled
306 * we can ditch all the existing _construct parameters in favour
307 * of just the userJobID - there are current open PRs towards this end.
309 public function getAvailableFields(): array {
310 $this->setFieldMetadata();
312 foreach ($this->getImportableFieldsMetadata() as $name => $field) {
313 if ($name === 'id' && $this->isSkipDuplicates()) {
314 // Duplicates are being skipped so id matching is not availble.
317 $return[$name] = $field['html']['label'] ??
$field['title'];
323 * Did the user specify duplicates should be skipped and not imported.
327 * @throws \API_Exception
329 protected function isSkipDuplicates(): bool {
330 return ((int) $this->getSubmittedValue('onDuplicate')) === CRM_Import_Parser
::DUPLICATE_SKIP
;
334 * Did the user specify duplicates should be filled with missing data.
338 protected function isFillDuplicates(): bool {
339 return ((int) $this->getSubmittedValue('onDuplicate')) === CRM_Import_Parser
::DUPLICATE_FILL
;
343 * Array of the fields that are actually part of the import process
344 * the position in the array also dictates their position in the import
348 protected $_activeFields = [];
351 * Cache the count of active fields
355 protected $_activeFieldCount;
358 * Cache of preview rows
365 * Filename of error data
369 protected $_errorFileName;
372 * Filename of duplicate data
376 protected $_duplicateFileName;
383 public $_contactType;
386 * @param string $contactType
388 * @return CRM_Import_Parser
390 public function setContactType(string $contactType): CRM_Import_Parser
{
391 $this->_contactType
= $contactType;
400 public $_contactSubType;
403 * @param int|null $contactSubType
407 public function setContactSubType(?
int $contactSubType): self
{
408 $this->_contactSubType
= $contactSubType;
415 public function __construct() {
416 $this->_maxLinesToProcess
= 0;
420 * Set and validate field values.
422 * @param array $elements
425 public function setActiveFieldValues($elements): void
{
426 $maxCount = count($elements) < $this->_activeFieldCount ?
count($elements) : $this->_activeFieldCount
;
427 for ($i = 0; $i < $maxCount; $i++
) {
428 $this->_activeFields
[$i]->setValue($elements[$i]);
431 // reset all the values that we did not have an equivalent import element
432 for (; $i < $this->_activeFieldCount
; $i++
) {
433 $this->_activeFields
[$i]->resetValue();
438 * Format the field values for input to the api.
441 * (reference) associative array of name/value pairs
443 public function &getActiveFieldParams() {
445 for ($i = 0; $i < $this->_activeFieldCount
; $i++
) {
446 if (isset($this->_activeFields
[$i]->_value
)
447 && !isset($params[$this->_activeFields
[$i]->_name
])
448 && !isset($this->_activeFields
[$i]->_related
)
451 $params[$this->_activeFields
[$i]->_name
] = $this->_activeFields
[$i]->_value
;
458 * Add progress bar to the import process. Calculates time remaining, status etc.
461 * status id of the import process saved in $config->uploadDir.
462 * @param bool $startImport
463 * True when progress bar is to be initiated.
464 * @param $startTimestamp
465 * Initial timestamp when the import was started.
466 * @param $prevTimestamp
467 * Previous timestamp when this function was last called.
468 * @param $totalRowCount
469 * Total number of rows in the import file.
471 * @return NULL|$currTimestamp
473 public function progressImport($statusID, $startImport = TRUE, $startTimestamp = NULL, $prevTimestamp = NULL, $totalRowCount = NULL) {
474 $statusFile = CRM_Core_Config
::singleton()->uploadDir
. "status_{$statusID}.txt";
477 $status = "<div class='description'> " . ts('No processing status reported yet.') . "</div>";
478 //do not force the browser to display the save dialog, CRM-7640
479 $contents = json_encode([0, $status]);
480 file_put_contents($statusFile, $contents);
483 $rowCount = $this->_rowCount ??
$this->_lineCount
;
484 $currTimestamp = time();
485 $time = ($currTimestamp - $prevTimestamp);
486 $recordsLeft = $totalRowCount - $rowCount;
487 if ($recordsLeft < 0) {
490 $estimatedTime = ($recordsLeft / 50) * $time;
491 $estMinutes = floor($estimatedTime / 60);
493 if ($estMinutes > 1) {
494 $timeFormatted = $estMinutes . ' ' . ts('minutes') . ' ';
495 $estimatedTime = $estimatedTime - ($estMinutes * 60);
497 $timeFormatted .= round($estimatedTime) . ' ' . ts('seconds');
498 $processedPercent = (int ) (($rowCount * 100) / $totalRowCount);
499 $statusMsg = ts('%1 of %2 records - %3 remaining',
500 [1 => $rowCount, 2 => $totalRowCount, 3 => $timeFormatted]
502 $status = "<div class=\"description\"> <strong>{$statusMsg}</strong></div>";
503 $contents = json_encode([$processedPercent, $status]);
505 file_put_contents($statusFile, $contents);
506 return $currTimestamp;
513 public function getSelectValues(): array {
515 foreach ($this->_fields
as $name => $field) {
516 $values[$name] = $field->_title
;
524 public function getSelectTypes() {
526 // This is only called from the MapField form in isolation now,
527 // so we need to set the metadata.
529 foreach ($this->_fields
as $name => $field) {
530 if (isset($field->_hasLocationType
)) {
531 $values[$name] = $field->_hasLocationType
;
540 public function getHeaderPatterns(): array {
542 foreach ($this->_fields
as $name => $field) {
543 if (isset($field->_headerPattern
)) {
544 $values[$name] = $field->_headerPattern
;
553 public function getDataPatterns():array {
555 foreach ($this->_fields
as $name => $field) {
556 $values[$name] = $field->_dataPattern
;
562 * Remove single-quote enclosures from a value array (row).
564 * @param array $values
565 * @param string $enclosure
569 public static function encloseScrub(&$values, $enclosure = "'") {
570 if (empty($values)) {
574 foreach ($values as $k => $v) {
575 $values[$k] = preg_replace("/^$enclosure(.*)$enclosure$/", '$1', $v);
586 public function setMaxLinesToProcess($max) {
587 $this->_maxLinesToProcess
= $max;
591 * Validate that we have the required fields to create the contact or find it to update.
593 * Note that the users duplicate selection affects this as follows
594 * - if they did not select an update variant then the id field is not
595 * permitted in the mapping - so we can assume the presence of id means
597 * - the external_identifier field is valid in place of the other fields
598 * when they have chosen update or fill - in this case we are only looking
599 * to update an existing contact.
601 * @param string $contactType
602 * @param array $params
603 * @param bool $isPermitExistingMatchFields
604 * True if the it is enough to have fields which will enable us to find
605 * an existing contact (eg. external_identifier).
606 * @param string $prefixString
607 * String to include in the exception (e.g '(Child of)' if we are validating
611 * @throws \CRM_Core_Exception
613 protected function validateRequiredContactFields(string $contactType, array $params, bool $isPermitExistingMatchFields = TRUE, $prefixString = ''): void
{
614 if (!empty($params['id'])) {
619 'first_name_last_name' => ['first_name' => ts('First Name'), 'last_name' => ts('Last Name')],
620 'email' => ts('Email Address'),
622 'Organization' => ['organization_name' => ts('Organization Name')],
623 'Household' => ['household_name' => ts('Household Name')],
625 if ($isPermitExistingMatchFields) {
626 $requiredFields['external_identifier'] = ts('External Identifier');
627 // Historically just an email has been accepted as it is 'usually good enough'
628 // for a dedupe rule look up - but really this is a stand in for
629 // whatever is needed to find an existing matching contact using the
630 // specified dedupe rule (or the default Unsupervised if not specified).
631 $requiredFields['email'] = ts('Email Address');
633 $this->validateRequiredFields($requiredFields, $params, $prefixString);
636 protected function doPostImportActions() {
637 $userJob = $this->getUserJob();
638 $summaryInfo = $userJob['metadata']['summary_info'] ??
[];
639 $actions = $userJob['metadata']['post_actions'] ??
[];
640 if (!empty($actions['group'])) {
641 $groupAdditions = $this->addImportedContactsToNewGroup($this->createdContacts
, $actions['group']);
642 foreach ($actions['group'] as $groupID) {
643 $summaryInfo['groups'][$groupID]['added'] +
= $groupAdditions[$groupID]['added'];
644 $summaryInfo['groups'][$groupID]['notAdded'] +
= $groupAdditions[$groupID]['notAdded'];
647 if (!empty($actions['tag'])) {
648 $tagAdditions = $this->tagImportedContactsWithNewTag($this->createdContacts
, $actions['tag']);
649 foreach ($actions['tag'] as $tagID) {
650 $summaryInfo['tags'][$tagID]['added'] +
= $tagAdditions[$tagID]['added'];
651 $summaryInfo['tags'][$tagID]['notAdded'] +
= $tagAdditions[$tagID]['notAdded'];
655 $this->userJob
['metadata']['summary_info'] = $summaryInfo;
656 UserJob
::update(FALSE)->addWhere('id', '=', $userJob['id'])->setValues(['metadata' => $this->userJob
['metadata']])->execute();
659 public function queue() {
660 $dataSource = $this->getDataSourceObject();
661 $totalRowCount = $totalRows = $dataSource->getRowCount(['new']);
662 $queue = Civi
::queue('user_job_' . $this->getUserJobID(), ['type' => 'Sql', 'error' => 'abort']);
665 while ($totalRows > 0) {
666 if ($totalRows < $batchSize) {
667 $batchSize = $totalRows;
669 $task = new CRM_Queue_Task(
670 [get_class($this), 'runImport'],
671 ['userJobID' => $this->getUserJobID(), 'limit' => $batchSize],
672 ts('Processed %1 rows out of %2', [1 => $offset +
$batchSize, 2 => $totalRowCount])
674 $queue->createItem($task);
675 $totalRows -= $batchSize;
676 $offset +
= $batchSize;
682 * Add imported contacts to groups.
684 * @param array $contactIDs
685 * @param array $groups
689 private function addImportedContactsToNewGroup(array $contactIDs, array $groups): array {
690 $groupAdditions = [];
691 foreach ($groups as $groupID) {
692 // @todo - this function has been in use historically but it does not seem
693 // to add much efficiency of get + create api calls
694 // and it doesn't give enough control over cache flushing for smaller batches.
695 // Note that the import updates a lot of enities & checking & updating the group
696 // shouldn't add much performance wise. However, cache flushing will
697 $addCount = CRM_Contact_BAO_GroupContact
::addContactsToGroup($contactIDs, $groupID);
698 $groupAdditions[$groupID] = [
699 'added' => (int) $addCount[1],
700 'notAdded' => (int) $addCount[2],
703 return $groupAdditions;
707 * Tag imported contacts.
709 * @param array $contactIDs
714 private function tagImportedContactsWithNewTag(array $contactIDs, array $tags) {
716 foreach ($tags as $tagID) {
717 // @todo - this function has been in use historically but it does not seem
718 // to add much efficiency of get + create api calls
719 // and it doesn't give enough control over cache flushing for smaller batches.
720 // Note that the import updates a lot of enities & checking & updating the group
721 // shouldn't add much performance wise. However, cache flushing will
722 $outcome = CRM_Core_BAO_EntityTag
::addEntitiesToTag($contactIDs, $tagID, 'civicrm_contact', FALSE);
723 $tagAdditions[$tagID] = ['added' => $outcome[1], 'notAdded' => $outcome[2]];
725 return $tagAdditions;
729 * Determines the file extension based on error code.
731 * @var int $type error code constant
734 public static function errorFileName($type) {
740 $config = CRM_Core_Config
::singleton();
741 $fileName = $config->uploadDir
. "sqlImport";
744 $fileName .= '.errors';
747 case self
::DUPLICATE
:
748 $fileName .= '.duplicates';
752 $fileName .= '.mismatch';
755 case self
::UNPARSED_ADDRESS_WARNING
:
756 $fileName .= '.unparsedAddress';
764 * Determines the file name based on error code.
766 * @var $type error code constant
769 public static function saveFileName($type) {
776 $fileName = 'Import_Errors.csv';
779 case self
::DUPLICATE
:
780 $fileName = 'Import_Duplicates.csv';
784 $fileName = 'Import_Mismatch.csv';
787 case self
::UNPARSED_ADDRESS_WARNING
:
788 $fileName = 'Import_Unparsed_Address.csv';
796 * Check if contact is a duplicate .
798 * @param array $formatValues
802 protected function checkContactDuplicate(&$formatValues) {
803 //retrieve contact id using contact dedupe rule
804 $formatValues['contact_type'] = $formatValues['contact_type'] ??
$this->getContactType();
805 $formatValues['version'] = 3;
806 require_once 'CRM/Utils/DeprecatedUtils.php';
807 $params = $formatValues;
808 static $cIndieFields = NULL;
809 static $defaultLocationId = NULL;
811 $contactType = $params['contact_type'];
812 if ($cIndieFields == NULL) {
813 $cTempIndieFields = CRM_Contact_BAO_Contact
::importableFields($contactType);
814 $cIndieFields = $cTempIndieFields;
816 $defaultLocation = CRM_Core_BAO_LocationType
::getDefault();
818 // set the value to default location id else set to 1
819 if (!$defaultLocationId = (int) $defaultLocation->id
) {
820 $defaultLocationId = 1;
824 $locationFields = CRM_Contact_BAO_Query
::$_locationSpecificFields;
826 $contactFormatted = [];
827 foreach ($params as $key => $field) {
828 if ($field == NULL ||
$field === '') {
831 // CRM-17040, Considering only primary contact when importing contributions. So contribution inserts into primary contact
832 // instead of soft credit contact.
833 if (is_array($field) && $key !== "soft_credit") {
834 foreach ($field as $value) {
836 if (is_array($value)) {
837 foreach ($value as $name => $testForEmpty) {
838 if ($name !== 'phone_type' &&
839 ($testForEmpty === '' ||
$testForEmpty == NULL)
850 $this->_civicrm_api3_deprecated_add_formatted_param($value, $contactFormatted);
856 $value = [$key => $field];
858 // check if location related field, then we need to add primary location type
859 if (in_array($key, $locationFields)) {
860 $value['location_type_id'] = $defaultLocationId;
862 elseif (array_key_exists($key, $cIndieFields)) {
863 $value['contact_type'] = $contactType;
866 $this->_civicrm_api3_deprecated_add_formatted_param($value, $contactFormatted);
869 $contactFormatted['contact_type'] = $contactType;
871 return _civicrm_api3_deprecated_duplicate_formatted_contact($contactFormatted);
875 * This function adds the contact variable in $values to the
876 * parameter list $params. For most cases, $values should have length 1. If
877 * the variable being added is a child of Location, a location_type_id must
878 * also be included. If it is a child of phone, a phone_type must be included.
880 * @param array $values
881 * The variable(s) to be added.
882 * @param array $params
883 * The structured parameter list.
885 * @return bool|CRM_Utils_Error
887 private function _civicrm_api3_deprecated_add_formatted_param(&$values, &$params) {
888 // @todo - like most functions in import ... most of this is cruft....
889 // Crawl through the possible classes:
902 // Cache the various object fields
903 static $fields = NULL;
905 if ($fields == NULL) {
909 // first add core contact values since for other Civi modules they are not added
910 require_once 'CRM/Contact/BAO/Contact.php';
911 $contactFields = CRM_Contact_DAO_Contact
::fields();
912 _civicrm_api3_store_values($contactFields, $values, $params);
914 if (isset($values['contact_type'])) {
915 // we're an individual/household/org property
917 $fields[$values['contact_type']] = CRM_Contact_DAO_Contact
::fields();
919 _civicrm_api3_store_values($fields[$values['contact_type']], $values, $params);
923 if (isset($values['individual_prefix'])) {
924 if (!empty($params['prefix_id'])) {
925 $prefixes = CRM_Core_PseudoConstant
::get('CRM_Contact_DAO_Contact', 'prefix_id');
926 $params['prefix'] = $prefixes[$params['prefix_id']];
929 $params['prefix'] = $values['individual_prefix'];
934 if (isset($values['individual_suffix'])) {
935 if (!empty($params['suffix_id'])) {
936 $suffixes = CRM_Core_PseudoConstant
::get('CRM_Contact_DAO_Contact', 'suffix_id');
937 $params['suffix'] = $suffixes[$params['suffix_id']];
940 $params['suffix'] = $values['individual_suffix'];
945 if (isset($values['gender'])) {
946 if (!empty($params['gender_id'])) {
947 $genders = CRM_Core_PseudoConstant
::get('CRM_Contact_DAO_Contact', 'gender_id');
948 $params['gender'] = $genders[$params['gender_id']];
951 $params['gender'] = $values['gender'];
956 // format the website params.
957 if (!empty($values['url'])) {
958 static $websiteFields;
959 if (!is_array($websiteFields)) {
960 require_once 'CRM/Core/DAO/Website.php';
961 $websiteFields = CRM_Core_DAO_Website
::fields();
963 if (!array_key_exists('website', $params) ||
964 !is_array($params['website'])
966 $params['website'] = [];
969 $websiteCount = count($params['website']);
970 _civicrm_api3_store_values($websiteFields, $values,
971 $params['website'][++
$websiteCount]
977 // get the formatted location blocks into params - w/ 3.0 format, CRM-4605
978 if (!empty($values['location_type_id'])) {
979 static $fields = NULL;
980 if ($fields == NULL) {
984 foreach (['Phone', 'Email', 'IM', 'OpenID', 'Phone_Ext'] as $block) {
985 $name = strtolower($block);
986 if (!array_key_exists($name, $values)) {
990 if ($name === 'phone_ext') {
994 // block present in value array.
995 if (!array_key_exists($name, $params) ||
!is_array($params[$name])) {
999 if (!array_key_exists($block, $fields)) {
1000 $className = "CRM_Core_DAO_$block";
1001 $fields[$block] =& $className::fields();
1004 $blockCnt = count($params[$name]);
1006 // copy value to dao field name.
1007 if ($name == 'im') {
1008 $values['name'] = $values[$name];
1011 _civicrm_api3_store_values($fields[$block], $values,
1012 $params[$name][++
$blockCnt]
1015 if (empty($params['id']) && ($blockCnt == 1)) {
1016 $params[$name][$blockCnt]['is_primary'] = TRUE;
1019 // we only process single block at a time.
1023 // handle address fields.
1024 if (!array_key_exists('address', $params) ||
!is_array($params['address'])) {
1025 $params['address'] = [];
1029 foreach ($params['address'] as $cnt => $addressBlock) {
1030 if (CRM_Utils_Array
::value('location_type_id', $values) ==
1031 CRM_Utils_Array
::value('location_type_id', $addressBlock)
1039 if (!array_key_exists('Address', $fields)) {
1040 $fields['Address'] = CRM_Core_DAO_Address
::fields();
1043 // Note: we doing multiple value formatting here for address custom fields, plus putting into right format.
1044 // The actual formatting (like date, country ..etc) for address custom fields is taken care of while saving
1045 // the address in CRM_Core_BAO_Address::create method
1046 if (!empty($values['location_type_id'])) {
1047 static $customFields = [];
1048 if (empty($customFields)) {
1049 $customFields = CRM_Core_BAO_CustomField
::getFields('Address');
1051 // make a copy of values, as we going to make changes
1052 $newValues = $values;
1053 foreach ($values as $key => $val) {
1054 $customFieldID = CRM_Core_BAO_CustomField
::getKeyID($key);
1055 if ($customFieldID && array_key_exists($customFieldID, $customFields)) {
1056 // mark an entry in fields array since we want the value of custom field to be copied
1057 $fields['Address'][$key] = NULL;
1059 $htmlType = $customFields[$customFieldID]['html_type'] ??
NULL;
1060 if (CRM_Core_BAO_CustomField
::isSerialized($customFields[$customFieldID]) && $val) {
1061 $mulValues = explode(',', $val);
1062 $customOption = CRM_Core_BAO_CustomOption
::getCustomOption($customFieldID, TRUE);
1063 $newValues[$key] = [];
1064 foreach ($mulValues as $v1) {
1065 foreach ($customOption as $v2) {
1066 if ((strtolower($v2['label']) == strtolower(trim($v1))) ||
1067 (strtolower($v2['value']) == strtolower(trim($v1)))
1069 if ($htmlType == 'CheckBox') {
1070 $newValues[$key][$v2['value']] = 1;
1073 $newValues[$key][] = $v2['value'];
1081 // consider new values
1082 $values = $newValues;
1085 _civicrm_api3_store_values($fields['Address'], $values, $params['address'][$addressCnt]);
1091 'supplemental_address_1',
1092 'supplemental_address_2',
1093 'supplemental_address_3',
1094 'StateProvince.name',
1097 foreach ($addressFields as $field) {
1098 if (array_key_exists($field, $values)) {
1099 if (!array_key_exists('address', $params)) {
1100 $params['address'] = [];
1102 $params['address'][$addressCnt][$field] = $values[$field];
1106 if ($addressCnt == 1) {
1108 $params['address'][$addressCnt]['is_primary'] = TRUE;
1113 if (isset($values['note'])) {
1115 if (!isset($params['note'])) {
1116 $params['note'] = [];
1118 $noteBlock = count($params['note']) +
1;
1120 $params['note'][$noteBlock] = [];
1121 if (!isset($fields['Note'])) {
1122 $fields['Note'] = CRM_Core_DAO_Note
::fields();
1125 // get the current logged in civicrm user
1126 $session = CRM_Core_Session
::singleton();
1127 $userID = $session->get('userID');
1130 $values['contact_id'] = $userID;
1133 _civicrm_api3_store_values($fields['Note'], $values, $params['note'][$noteBlock]);
1138 // Check for custom field values
1140 if (empty($fields['custom'])) {
1141 $fields['custom'] = &CRM_Core_BAO_CustomField
::getFields(CRM_Utils_Array
::value('contact_type', $values),
1142 FALSE, FALSE, NULL, NULL, FALSE, FALSE, FALSE
1146 foreach ($values as $key => $value) {
1147 if ($customFieldID = CRM_Core_BAO_CustomField
::getKeyID($key)) {
1148 // check if it's a valid custom field id
1150 if (!array_key_exists($customFieldID, $fields['custom'])) {
1151 return civicrm_api3_create_error('Invalid custom field ID');
1154 $params[$key] = $value;
1161 * Parse a field which could be represented by a label or name value rather than the DB value.
1163 * We will try to match name first or (per https://lab.civicrm.org/dev/core/issues/1285 if we have an id.
1165 * but if not available then see if we have a label that can be converted to a name.
1167 * @param string|int|null $submittedValue
1168 * @param array $fieldSpec
1169 * Metadata for the field
1173 protected function parsePseudoConstantField($submittedValue, $fieldSpec) {
1174 // dev/core#1289 Somehow we have wound up here but the BAO has not been specified in the fieldspec so we need to check this but future us problem, for now lets just return the submittedValue
1175 if (!isset($fieldSpec['bao'])) {
1176 return $submittedValue;
1178 /* @var \CRM_Core_DAO $bao */
1179 $bao = $fieldSpec['bao'];
1180 // For historical reasons use validate as context - ie disabled name matches ARE permitted.
1181 $nameOptions = $bao::buildOptions($fieldSpec['name'], 'validate');
1182 if (isset($nameOptions[$submittedValue])) {
1183 return $submittedValue;
1185 if (in_array($submittedValue, $nameOptions)) {
1186 return array_search($submittedValue, $nameOptions, TRUE);
1189 $labelOptions = array_flip($bao::buildOptions($fieldSpec['name'], 'match'));
1190 if (isset($labelOptions[$submittedValue])) {
1191 return array_search($labelOptions[$submittedValue], $nameOptions, TRUE);
1197 * This is code extracted from 4 places where this exact snippet was being duplicated.
1199 * FIXME: Extracting this was a first step, but there's also
1200 * 1. Inconsistency in the way other select options are handled.
1201 * Contribution adds handling for Select/Radio/Autocomplete
1202 * Participant/Activity only handles Select/Radio and misses Autocomplete
1203 * Membership is missing all of it
1204 * 2. Inconsistency with the way this works vs. how it's implemented in Contact import.
1206 * @param $customFieldID
1211 public static function unserializeCustomValue($customFieldID, $value, $fieldType) {
1212 $mulValues = explode(',', $value);
1213 $customOption = CRM_Core_BAO_CustomOption
::getCustomOption($customFieldID, TRUE);
1215 foreach ($mulValues as $v1) {
1216 foreach ($customOption as $customValueID => $customLabel) {
1217 $customValue = $customLabel['value'];
1218 if ((strtolower(trim($customLabel['label'])) == strtolower(trim($v1))) ||
1219 (strtolower(trim($customValue)) == strtolower(trim($v1)))
1221 $values[] = $customValue;
1229 * Validate that the field requirements are met in the params.
1231 * @param array $requiredFields
1232 * @param array $params
1233 * An array of required fields (fieldName => label)
1234 * - note this follows the and / or array nesting we see in permission checks
1237 * 'email' => ts('Email'),
1238 * ['first_name' => ts('First Name'), 'last_name' => ts('Last Name')]
1240 * Means 'email' OR 'first_name AND 'last_name'.
1241 * @param string $prefixString
1243 * @throws \CRM_Core_Exception Exception thrown if field requirements are not met.
1245 protected function validateRequiredFields(array $requiredFields, array $params, $prefixString): void
{
1246 $missingFields = [];
1247 foreach ($requiredFields as $key => $required) {
1248 if (!is_array($required)) {
1249 $importParameter = $params[$key] ??
[];
1250 if (!is_array($importParameter)) {
1251 if (!empty($importParameter)) {
1256 foreach ($importParameter as $locationValues) {
1257 if (!empty($locationValues[$key])) {
1263 $missingFields[$key] = $required;
1266 foreach ($required as $field => $label) {
1267 if (empty($params[$field])) {
1268 $missing[$field] = $label;
1271 if (empty($missing)) {
1274 $missingFields[$key] = implode(' ' . ts('and') . ' ', $missing);
1277 throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields));
1281 * Get the field value, transformed by metadata.
1283 * @param string $fieldName
1284 * @param string|int $importedValue
1285 * Value as it came in from the datasource.
1287 * @return string|array|bool|int
1288 * @throws \API_Exception
1290 protected function getTransformedFieldValue(string $fieldName, $importedValue) {
1291 if (empty($importedValue)) {
1292 return $importedValue;
1294 $fieldMetadata = $this->getFieldMetadata($fieldName);
1295 if (!empty($fieldMetadata['serialize']) && count(explode(',', $importedValue)) > 1) {
1297 foreach (explode(',', $importedValue) as $value) {
1298 $values[] = $this->getTransformedFieldValue($fieldName, $value);
1302 if ($fieldName === 'url') {
1303 return CRM_Utils_Rule
::url($importedValue) ?
$importedValue : 'invalid_import_value';
1306 if ($fieldName === 'email') {
1307 return CRM_Utils_Rule
::email($importedValue) ?
$importedValue : 'invalid_import_value';
1310 if ($fieldMetadata['type'] === CRM_Utils_Type
::T_FLOAT
) {
1311 return CRM_Utils_Rule
::numeric($importedValue) ?
$importedValue : 'invalid_import_value';
1313 if ($fieldMetadata['type'] === CRM_Utils_Type
::T_MONEY
) {
1314 return CRM_Utils_Rule
::money($importedValue, TRUE) ? CRM_Utils_Rule
::cleanMoney($importedValue) : 'invalid_import_value';
1316 if ($fieldMetadata['type'] === CRM_Utils_Type
::T_BOOLEAN
) {
1317 $value = CRM_Utils_String
::strtoboolstr($importedValue);
1318 if ($value !== FALSE) {
1319 return (bool) $value;
1321 return 'invalid_import_value';
1323 if ($fieldMetadata['type'] === CRM_Utils_Type
::T_DATE ||
$fieldMetadata['type'] === (CRM_Utils_Type
::T_DATE + CRM_Utils_Type
::T_TIME
) ||
$fieldMetadata['type'] === CRM_Utils_Type
::T_TIMESTAMP
) {
1324 $value = CRM_Utils_Date
::formatDate($importedValue, $this->getSubmittedValue('dateFormats'));
1325 return ($value) ?
: 'invalid_import_value';
1327 $options = $this->getFieldOptions($fieldName);
1328 if ($options !== FALSE) {
1329 if ($this->isAmbiguous($fieldName, $importedValue)) {
1330 // We can't transform it at this stage. Perhaps later we can with
1331 // other information such as country.
1332 return $importedValue;
1335 $comparisonValue = is_numeric($importedValue) ?
$importedValue : mb_strtolower($importedValue);
1336 return $options[$comparisonValue] ??
'invalid_import_value';
1338 return $importedValue;
1342 * @param string $fieldName
1344 * @return false|array
1346 * @throws \API_Exception
1348 protected function getFieldOptions(string $fieldName) {
1349 return $this->getFieldMetadata($fieldName, TRUE)['options'];
1353 * Get the metadata for the field.
1355 * @param string $fieldName
1356 * @param bool $loadOptions
1357 * @param bool $limitToContactType
1358 * Only show fields for the type to import (not appropriate when looking up
1359 * related contact fields).
1363 * @throws \API_Exception
1364 * @throws \Civi\API\Exception\NotImplementedException
1366 protected function getFieldMetadata(string $fieldName, bool $loadOptions = FALSE, $limitToContactType = FALSE): array {
1368 $fieldMap = $this->getOddlyMappedMetadataFields();
1369 $fieldMapName = empty($fieldMap[$fieldName]) ?
$fieldName : $fieldMap[$fieldName];
1371 // This whole business of only loading metadata for one type when we actually need it for all is ... dubious.
1372 if (empty($this->getImportableFieldsMetadata()[$fieldMapName])) {
1373 if ($loadOptions ||
!$limitToContactType) {
1374 $this->importableFieldsMetadata
[$fieldMapName] = CRM_Contact_BAO_Contact
::importableFields('All')[$fieldMapName];
1378 $fieldMetadata = $this->getImportableFieldsMetadata()[$fieldMapName];
1379 if ($loadOptions && !isset($fieldMetadata['options'])) {
1380 if (($fieldMetadata['data_type'] ??
'') === 'StateProvince') {
1381 // Probably already loaded and also supports abbreviations - eg. NSW.
1382 // Supporting for core AND custom state fields is more consistent.
1383 $this->importableFieldsMetadata
[$fieldMapName]['options'] = $this->getFieldOptions('state_province_id');
1384 return $this->importableFieldsMetadata
[$fieldMapName];
1386 if (($fieldMetadata['data_type'] ??
'') === 'Country') {
1387 // Probably already loaded and also supports abbreviations - eg. NSW.
1388 // Supporting for core AND custom state fields is more consistent.
1389 $this->importableFieldsMetadata
[$fieldMapName]['options'] = $this->getFieldOptions('country_id');
1390 return $this->importableFieldsMetadata
[$fieldMapName];
1392 $optionFieldName = empty($fieldMap[$fieldName]) ?
$fieldMetadata['name'] : $fieldName;
1394 if (!empty($fieldMetadata['custom_group_id'])) {
1395 $customField = CustomField
::get(FALSE)
1396 ->addWhere('id', '=', $fieldMetadata['custom_field_id'])
1397 ->addSelect('name', 'custom_group_id.name')
1400 $optionFieldName = $customField['custom_group_id.name'] . '.' . $customField['name'];
1402 $options = civicrm_api4($this->getFieldEntity($fieldName), 'getFields', [
1403 'loadOptions' => ['id', 'name', 'label', 'abbr'],
1404 'where' => [['name', '=', $optionFieldName]],
1405 'select' => ['options'],
1406 ])->first()['options'];
1407 if (is_array($options)) {
1408 // We create an array of the possible variants - notably including
1409 // name AND label as either might be used. We also lower case before checking
1411 foreach ($options as $option) {
1412 $idKey = is_numeric($option['id']) ?
$option['id'] : mb_strtolower($option['id']);
1413 $values[$idKey] = $option['id'];
1414 foreach (['name', 'label', 'abbr'] as $key) {
1415 $optionValue = mb_strtolower($option[$key] ??
'');
1416 if ($optionValue !== '') {
1417 if (isset($values[$optionValue]) && $values[$optionValue] !== $option['id']) {
1418 if (!isset($this->ambiguousOptions
[$fieldName][$optionValue])) {
1419 $this->ambiguousOptions
[$fieldName][$optionValue] = [$values[$optionValue]];
1421 $this->ambiguousOptions
[$fieldName][$optionValue][] = $option['id'];
1424 $values[$optionValue] = $option['id'];
1429 $this->importableFieldsMetadata
[$fieldMapName]['options'] = $values;
1432 $this->importableFieldsMetadata
[$fieldMapName]['options'] = $options;
1434 return $this->importableFieldsMetadata
[$fieldMapName];
1436 return $fieldMetadata;
1440 * @param $customFieldID
1442 * @param array $fieldMetaData
1447 protected function validateCustomField($customFieldID, $value, array $fieldMetaData, $dateType): ?
string {
1448 /* validate the data against the CF type */
1451 $dataType = $fieldMetaData['data_type'];
1452 $htmlType = $fieldMetaData['html_type'];
1453 $isSerialized = CRM_Core_BAO_CustomField
::isSerialized($fieldMetaData);
1454 if ($dataType === 'Date') {
1455 $params = ['date_field' => $value];
1456 if (CRM_Utils_Date
::convertToDefaultDate($params, $dateType, 'date_field')) {
1459 return $fieldMetaData['label'];
1461 elseif ($dataType === 'Boolean') {
1462 if (CRM_Utils_String
::strtoboolstr($value) === FALSE) {
1463 return $fieldMetaData['label'] . '::' . $fieldMetaData['groupTitle'];
1466 // need not check for label filed import
1467 $selectHtmlTypes = [
1472 if ((!$isSerialized && !in_array($htmlType, $selectHtmlTypes)) ||
$dataType == 'Boolean' ||
$dataType == 'ContactReference') {
1473 $valid = CRM_Core_BAO_CustomValue
::typecheck($dataType, $value);
1475 return $fieldMetaData['label'];
1479 // check for values for custom fields for checkboxes and multiselect
1480 if ($isSerialized && $dataType != 'ContactReference') {
1481 $mulValues = array_filter(explode(',', str_replace('|', ',', trim($value))), 'strlen');
1482 $customOption = CRM_Core_BAO_CustomOption
::getCustomOption($customFieldID, TRUE);
1483 foreach ($mulValues as $v1) {
1486 foreach ($customOption as $v2) {
1487 if ((strtolower(trim($v2['label'])) == strtolower(trim($v1))) ||
(strtolower(trim($v2['value'])) == strtolower(trim($v1)))) {
1493 return $fieldMetaData['label'];
1497 elseif ($htmlType == 'Select' ||
($htmlType == 'Radio' && $dataType != 'Boolean')) {
1498 $customOption = CRM_Core_BAO_CustomOption
::getCustomOption($customFieldID, TRUE);
1500 foreach ($customOption as $v2) {
1501 if ((strtolower(trim($v2['label'])) == strtolower(trim($value))) ||
(strtolower(trim($v2['value'])) == strtolower(trim($value)))) {
1506 return $fieldMetaData['label'];
1515 * Get the entity for the given field.
1517 * @param string $fieldName
1519 * @return mixed|null
1520 * @throws \API_Exception
1522 protected function getFieldEntity(string $fieldName) {
1523 if ($fieldName === 'do_not_import') {
1526 if (in_array($fieldName, ['email_greeting_id', 'postal_greeting_id', 'addressee_id'], TRUE)) {
1529 $metadata = $this->getFieldMetadata($fieldName);
1530 if (!isset($metadata['entity'])) {
1531 return in_array($metadata['extends'], ['Individual', 'Organization', 'Household'], TRUE) ?
'Contact' : $metadata['extends'];
1534 // Our metadata for these is fugly. Handling the fugliness during retrieval.
1535 if (in_array($metadata['entity'], ['Country', 'StateProvince', 'County'], TRUE)) {
1538 return $metadata['entity'];
1542 * Validate the import file, updating the import table with results.
1544 * @throws \API_Exception
1545 * @throws \CRM_Core_Exception
1547 public function validate(): void
{
1548 $dataSource = $this->getDataSourceObject();
1549 while ($row = $dataSource->getRow()) {
1551 $rowNumber = $row['_id'];
1552 $values = array_values($row);
1553 $this->validateValues($values);
1554 $this->setImportStatus($rowNumber, 'NEW', '');
1556 catch (CRM_Core_Exception
$e) {
1557 $this->setImportStatus($rowNumber, 'ERROR', $e->getMessage());
1563 * Search the value for the string 'invalid_import_value'.
1565 * If the string is found it indicates the fields was rejected
1566 * during `getTransformedValue` as not having valid data.
1568 * @param string|array|int $value
1569 * @param string $key
1570 * @param string $prefixString
1573 * @throws \API_Exception
1575 protected function getInvalidValues($value, string $key = '', string $prefixString = ''): array {
1577 if ($value === 'invalid_import_value') {
1578 $metadata = $this->getFieldMetadata($key);
1579 $errors[] = $prefixString . ($metadata['html']['label'] ??
$metadata['title']);
1581 elseif (is_array($value)) {
1582 foreach ($value as $innerKey => $innerValue) {
1583 $result = $this->getInvalidValues($innerValue, $innerKey, $prefixString);
1584 if (!empty($result)) {
1585 $errors = array_merge($result, $errors);
1589 return array_filter($errors);
1593 * Get the available countries.
1595 * If the site is not configured with a restriction then all countries are valid
1596 * but otherwise only a select array are.
1598 * @return array|false
1599 * FALSE indicates no restrictions.
1601 protected function getAvailableCountries() {
1602 if ($this->availableCountries
=== NULL) {
1603 $availableCountries = Civi
::settings()->get('countryLimit');
1604 $this->availableCountries
= !empty($availableCountries) ?
array_fill_keys($availableCountries, TRUE) : FALSE;
1606 return $this->availableCountries
;
1610 * Get the metadata field for which importable fields does not key the actual field name.
1614 protected function getOddlyMappedMetadataFields(): array {
1616 'country_id' => 'country',
1617 'state_province_id' => 'state_province',
1618 'county_id' => 'county',
1619 'email_greeting_id' => 'email_greeting',
1620 'postal_greeting_id' => 'postal_greeting',
1621 'addressee_id' => 'addressee',
1626 * Get the default country for the site.
1630 protected function getSiteDefaultCountry(): int {
1631 if (!isset($this->siteDefaultCountry
)) {
1632 $this->siteDefaultCountry
= (int) Civi
::settings()->get('defaultContactCountry');
1634 return $this->siteDefaultCountry
;
1638 * Is the option ambiguous.
1640 * @param string $fieldName
1641 * @param string $importedValue
1643 protected function isAmbiguous(string $fieldName, $importedValue): bool {
1644 return !empty($this->ambiguousOptions
[$fieldName][mb_strtolower($importedValue)]);
1648 * Get the civicrm_mapping_field appropriate layout for the mapper input.
1650 * For simple parsers (not contribution or contact) the input looks like
1651 * ['first_name', 'custom_32']
1652 * and it is converted to
1654 * ['name' => 'first_name', 'mapping_id' => 1, 'column_number' => 5],
1656 * @param array $fieldMapping
1657 * @param int $mappingID
1658 * @param int $columnNumber
1662 public function getMappingFieldFromMapperInput(array $fieldMapping, int $mappingID, int $columnNumber): array {
1664 'name' => $fieldMapping[0],
1665 'mapping_id' => $mappingID,
1666 'column_number' => $columnNumber,
1671 * @param array $mappedField
1672 * Field detail as would be saved in field_mapping table
1673 * or as returned from getMappingFieldFromMapperInput
1676 * @throws \API_Exception
1678 public function getMappedFieldLabel(array $mappedField): string {
1679 $this->setFieldMetadata();
1680 return $this->getFieldMetadata($mappedField['name'])['title'];
1684 * Get the row from the csv mapped to our parameters.
1686 * @param array $values
1689 * @throws \API_Exception
1691 public function getMappedRow(array $values): array {
1693 foreach ($this->getFieldMappings() as $i => $mappedField) {
1694 if ($mappedField['name'] === 'do_not_import') {
1697 if ($mappedField['name']) {
1698 $params[$this->getFieldMetadata($mappedField['name'])['name']] = $this->getTransformedFieldValue($mappedField['name'], $values[$i]);
1705 * Get the field mappings for the import.
1707 * This is the same format as saved in civicrm_mapping_field except
1708 * that location_type_id = 'Primary' rather than empty where relevant.
1709 * Also 'im_provider_id' is mapped to the 'real' field name 'provider_id'
1712 * @throws \API_Exception
1714 protected function getFieldMappings(): array {
1716 $mapper = $this->getSubmittedValue('mapper');
1717 foreach ($mapper as $i => $mapperRow) {
1718 $mappedField = $this->getMappingFieldFromMapperInput($mapperRow, 0, $i);
1719 // Just for clarity since 0 is a pseudo-value
1720 unset($mappedField['mapping_id']);
1721 $mappedFields[] = $mappedField;
1723 return $mappedFields;
1729 * @param \CRM_Queue_TaskContext $taskContext
1731 * @param int $userJobID
1735 * @throws \API_Exception
1736 * @throws \CRM_Core_Exception
1738 public static function runImport($taskContext, $userJobID, $limit) {
1739 $userJob = UserJob
::get()->addWhere('id', '=', $userJobID)->addSelect('type_id')->execute()->first();
1740 $parserClass = NULL;
1741 foreach (CRM_Core_BAO_UserJob
::getTypes() as $userJobType) {
1742 if ($userJob['type_id'] === $userJobType['id']) {
1743 $parserClass = $userJobType['class'];
1746 /* @var \CRM_Import_Parser $parser */
1747 $parser = new $parserClass();
1748 $parser->setUserJobID($userJobID);
1749 // Not sure if we still need to init....
1751 $dataSource = $parser->getDataSourceObject();
1752 $dataSource->setStatuses(['new']);
1753 $dataSource->setLimit($limit);
1755 while ($row = $dataSource->getRow()) {
1756 $values = array_values($row);
1757 $parser->import($values);
1759 $parser->doPostImportActions();
1764 * Check if an error in custom data.
1766 * @deprecated all of this is duplicated if getTransformedValue is used.
1768 * @param array $params
1769 * @param string $errorMessage
1770 * A string containing all the error-fields.
1772 * @param null $csType
1774 public function isErrorInCustomData($params, &$errorMessage, $csType = NULL) {
1775 $dateType = CRM_Core_Session
::singleton()->get("dateTypes");
1778 if (!empty($params['contact_sub_type'])) {
1779 $csType = $params['contact_sub_type'] ??
NULL;
1782 if (empty($params['contact_type'])) {
1783 $params['contact_type'] = 'Individual';
1786 // get array of subtypes - CRM-18708
1787 if (in_array($csType, CRM_Contact_BAO_ContactType
::basicTypes(TRUE), TRUE)) {
1788 $csType = $this->getSubtypes($params['contact_type']);
1791 if (is_array($csType)) {
1792 // fetch custom fields for every subtype and add it to $customFields array
1795 foreach ($csType as $cType) {
1796 $customFields +
= CRM_Core_BAO_CustomField
::getFields($params['contact_type'], FALSE, FALSE, $cType);
1800 $customFields = CRM_Core_BAO_CustomField
::getFields($params['contact_type'], FALSE, FALSE, $csType);
1803 foreach ($params as $key => $value) {
1804 if ($customFieldID = CRM_Core_BAO_CustomField
::getKeyID($key)) {
1805 //For address custom fields, we do get actual custom field value as an inner array of
1806 //values so need to modify
1807 if (!array_key_exists($customFieldID, $customFields)) {
1808 return ts('field ID');
1810 /* check if it's a valid custom field id */
1811 $errors[] = $this->validateCustomField($customFieldID, $value, $customFields[$customFieldID], $dateType);
1815 $errorMessage .= ($errorMessage ?
'; ' : '') . implode('; ', array_filter($errors));
1820 * get subtypes given the contact type
1822 * @param string $contactType
1823 * @return array $subTypes
1825 protected function getSubtypes($contactType) {
1827 $types = CRM_Contact_BAO_ContactType
::subTypeInfo($contactType);
1829 if (count($types) > 0) {
1830 foreach ($types as $type) {
1831 $subTypes[] = $type['name'];
1838 * Update the status of the import row to reflect the processing outcome.
1841 * @param string $status
1842 * @param string $message
1843 * @param int|null $entityID
1844 * Optional created entity ID
1846 * @noinspection PhpDocMissingThrowsInspection
1847 * @noinspection PhpUnhandledExceptionInspection
1849 protected function setImportStatus(int $id, string $status, string $message, ?
int $entityID = NULL): void
{
1850 $this->getDataSourceObject()->updateStatus($id, $status, $message, $entityID);
1854 * Convert any given date string to default date array.
1856 * @param array $params
1857 * Has given date-format.
1858 * @param array $formatted
1859 * Store formatted date in this array.
1860 * @param int $dateType
1862 * @param string $dateParam
1865 public static function formatCustomDate(&$params, &$formatted, $dateType, $dateParam) {
1867 CRM_Utils_Date
::convertToDefaultDate($params, $dateType, $dateParam);
1868 $formatted[$dateParam] = CRM_Utils_Date
::processDate($params[$dateParam]);