Merge pull request #23619 from darrick/pull/23534
[civicrm-core.git] / CRM / Import / Parser.php
... / ...
CommitLineData
1<?php
2/*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
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 +--------------------------------------------------------------------+
10 */
11
12use Civi\Api4\UserJob;
13
14/**
15 *
16 * @package CRM
17 * @copyright CiviCRM LLC https://civicrm.org/licensing
18 */
19abstract class CRM_Import_Parser {
20 /**
21 * Settings
22 */
23 const MAX_WARNINGS = 25, DEFAULT_TIMEOUT = 30;
24
25 /**
26 * Return codes
27 */
28 const VALID = 1, WARNING = 2, ERROR = 4, CONFLICT = 8, STOP = 16, DUPLICATE = 32, MULTIPLE_DUPE = 64, NO_MATCH = 128, UNPARSED_ADDRESS_WARNING = 256;
29
30 /**
31 * Parser modes
32 */
33 const MODE_MAPFIELD = 1, MODE_PREVIEW = 2, MODE_SUMMARY = 4, MODE_IMPORT = 8;
34
35 /**
36 * Codes for duplicate record handling
37 */
38 const DUPLICATE_SKIP = 1, DUPLICATE_UPDATE = 4, DUPLICATE_FILL = 8, DUPLICATE_NOCHECK = 16;
39
40 /**
41 * Contact types
42 */
43 const CONTACT_INDIVIDUAL = 1, CONTACT_HOUSEHOLD = 2, CONTACT_ORGANIZATION = 4;
44
45 /**
46 * User job id.
47 *
48 * This is the primary key of the civicrm_user_job table which is used to
49 * track the import.
50 *
51 * @var int
52 */
53 protected $userJobID;
54
55 /**
56 * Fields which are being handled by metadata formatting & validation functions.
57 *
58 * This is intended as a temporary parameter as we phase in metadata handling.
59 *
60 * The end result is that all fields will be & this will go but for now it is
61 * opt in.
62 *
63 * @var array
64 */
65 protected $metadataHandledFields = [];
66
67 /**
68 * @return int|null
69 */
70 public function getUserJobID(): ?int {
71 return $this->userJobID;
72 }
73
74 /**
75 * Set user job ID.
76 *
77 * @param int $userJobID
78 *
79 * @return self
80 */
81 public function setUserJobID(int $userJobID): self {
82 $this->userJobID = $userJobID;
83 return $this;
84 }
85
86 /**
87 * Countries that the site is restricted to
88 *
89 * @var array|false
90 */
91 private $availableCountries;
92
93 /**
94 * Get User Job.
95 *
96 * API call to retrieve the userJob row.
97 *
98 * @return array
99 *
100 * @throws \API_Exception
101 */
102 protected function getUserJob(): array {
103 return UserJob::get()
104 ->addWhere('id', '=', $this->getUserJobID())
105 ->execute()
106 ->first();
107 }
108
109 /**
110 * Get the relevant datasource object.
111 *
112 * @return \CRM_Import_DataSource|null
113 *
114 * @throws \API_Exception
115 */
116 protected function getDataSourceObject(): ?CRM_Import_DataSource {
117 $className = $this->getSubmittedValue('dataSource');
118 if ($className) {
119 /* @var CRM_Import_DataSource $dataSource */
120 return new $className($this->getUserJobID());
121 }
122 return NULL;
123 }
124
125 /**
126 * Get the submitted value, as stored on the user job.
127 *
128 * @param string $fieldName
129 *
130 * @return mixed
131 *
132 * @throws \API_Exception
133 */
134 protected function getSubmittedValue(string $fieldName) {
135 return $this->getUserJob()['metadata']['submitted_values'][$fieldName];
136 }
137
138 /**
139 * Has the import completed.
140 *
141 * @return bool
142 *
143 * @throws \API_Exception
144 * @throws \CRM_Core_Exception
145 */
146 public function isComplete() :bool {
147 return $this->getDataSourceObject()->isCompleted();
148 }
149
150 /**
151 * Get configured contact type.
152 *
153 * @throws \API_Exception
154 */
155 protected function getContactType() {
156 if (!$this->_contactType) {
157 $contactTypeMapping = [
158 CRM_Import_Parser::CONTACT_INDIVIDUAL => 'Individual',
159 CRM_Import_Parser::CONTACT_HOUSEHOLD => 'Household',
160 CRM_Import_Parser::CONTACT_ORGANIZATION => 'Organization',
161 ];
162 $this->_contactType = $contactTypeMapping[$this->getSubmittedValue('contactType')];
163 }
164 return $this->_contactType;
165 }
166
167 /**
168 * Get configured contact type.
169 *
170 * @return string|null
171 *
172 * @throws \API_Exception
173 */
174 public function getContactSubType() {
175 if (!$this->_contactSubType) {
176 $this->_contactSubType = $this->getSubmittedValue('contactSubType');
177 }
178 return $this->_contactSubType;
179 }
180
181 /**
182 * Total number of non empty lines
183 * @var int
184 */
185 protected $_totalCount;
186
187 /**
188 * Running total number of valid lines
189 * @var int
190 */
191 protected $_validCount;
192
193 /**
194 * Running total number of invalid rows
195 * @var int
196 */
197 protected $_invalidRowCount;
198
199 /**
200 * Maximum number of non-empty/comment lines to process
201 *
202 * @var int
203 */
204 protected $_maxLinesToProcess;
205
206 /**
207 * Array of error lines, bounded by MAX_ERROR
208 * @var array
209 */
210 protected $_errors;
211
212 /**
213 * Total number of duplicate (from database) lines
214 * @var int
215 */
216 protected $_duplicateCount;
217
218 /**
219 * Array of duplicate lines
220 * @var array
221 */
222 protected $_duplicates;
223
224 /**
225 * Maximum number of warnings to store
226 * @var int
227 */
228 protected $_maxWarningCount = self::MAX_WARNINGS;
229
230 /**
231 * Array of warning lines, bounded by MAX_WARNING
232 * @var array
233 */
234 protected $_warnings;
235
236 /**
237 * Array of all the fields that could potentially be part
238 * of this import process
239 * @var array
240 */
241 protected $_fields;
242
243 /**
244 * Metadata for all available fields, keyed by unique name.
245 *
246 * This is intended to supercede $_fields which uses a special sauce format which
247 * importableFieldsMetadata uses the standard getfields type format.
248 *
249 * @var array
250 */
251 protected $importableFieldsMetadata = [];
252
253 /**
254 * Get metadata for all importable fields in std getfields style format.
255 *
256 * @return array
257 */
258 public function getImportableFieldsMetadata(): array {
259 return $this->importableFieldsMetadata;
260 }
261
262 /**
263 * Set metadata for all importable fields in std getfields style format.
264 *
265 * @param array $importableFieldsMetadata
266 */
267 public function setImportableFieldsMetadata(array $importableFieldsMetadata): void {
268 $this->importableFieldsMetadata = $importableFieldsMetadata;
269 }
270
271 /**
272 * Gets the fields available for importing in a key-name, title format.
273 *
274 * @return array
275 * eg. ['first_name' => 'First Name'.....]
276 *
277 * @throws \API_Exception
278 *
279 * @todo - we are constructing the metadata before we
280 * have set the contact type so we re-do it here.
281 *
282 * Once we have cleaned up the way the mapper is handled
283 * we can ditch all the existing _construct parameters in favour
284 * of just the userJobID - there are current open PRs towards this end.
285 */
286 public function getAvailableFields(): array {
287 $this->setFieldMetadata();
288 $return = [];
289 foreach ($this->getImportableFieldsMetadata() as $name => $field) {
290 if ($name === 'id' && $this->isSkipDuplicates()) {
291 // Duplicates are being skipped so id matching is not availble.
292 continue;
293 }
294 $return[$name] = $field['html']['label'] ?? $field['title'];
295 }
296 return $return;
297 }
298
299 /**
300 * Did the user specify duplicates should be skipped and not imported.
301 *
302 * @return bool
303 *
304 * @throws \API_Exception
305 */
306 protected function isSkipDuplicates(): bool {
307 return ((int) $this->getSubmittedValue('onDuplicate')) === CRM_Import_Parser::DUPLICATE_SKIP;
308 }
309
310 /**
311 * Array of the fields that are actually part of the import process
312 * the position in the array also dictates their position in the import
313 * file
314 * @var array
315 */
316 protected $_activeFields;
317
318 /**
319 * Cache the count of active fields
320 *
321 * @var int
322 */
323 protected $_activeFieldCount;
324
325 /**
326 * Cache of preview rows
327 *
328 * @var array
329 */
330 protected $_rows;
331
332 /**
333 * Filename of error data
334 *
335 * @var string
336 */
337 protected $_errorFileName;
338
339 /**
340 * Filename of duplicate data
341 *
342 * @var string
343 */
344 protected $_duplicateFileName;
345
346 /**
347 * Contact type
348 *
349 * @var string
350 */
351 public $_contactType;
352
353 /**
354 * @param string $contactType
355 *
356 * @return CRM_Import_Parser
357 */
358 public function setContactType(string $contactType): CRM_Import_Parser {
359 $this->_contactType = $contactType;
360 return $this;
361 }
362
363 /**
364 * Contact sub-type
365 *
366 * @var int|null
367 */
368 public $_contactSubType;
369
370 /**
371 * @param int|null $contactSubType
372 *
373 * @return self
374 */
375 public function setContactSubType(?int $contactSubType): self {
376 $this->_contactSubType = $contactSubType;
377 return $this;
378 }
379
380 /**
381 * Class constructor.
382 */
383 public function __construct() {
384 $this->_maxLinesToProcess = 0;
385 }
386
387 /**
388 * Set and validate field values.
389 *
390 * @param array $elements
391 * array.
392 */
393 public function setActiveFieldValues($elements): void {
394 $maxCount = count($elements) < $this->_activeFieldCount ? count($elements) : $this->_activeFieldCount;
395 for ($i = 0; $i < $maxCount; $i++) {
396 $this->_activeFields[$i]->setValue($elements[$i]);
397 }
398
399 // reset all the values that we did not have an equivalent import element
400 for (; $i < $this->_activeFieldCount; $i++) {
401 $this->_activeFields[$i]->resetValue();
402 }
403 }
404
405 /**
406 * Format the field values for input to the api.
407 *
408 * @return array
409 * (reference) associative array of name/value pairs
410 */
411 public function &getActiveFieldParams() {
412 $params = [];
413 for ($i = 0; $i < $this->_activeFieldCount; $i++) {
414 if (isset($this->_activeFields[$i]->_value)
415 && !isset($params[$this->_activeFields[$i]->_name])
416 && !isset($this->_activeFields[$i]->_related)
417 ) {
418
419 $params[$this->_activeFields[$i]->_name] = $this->_activeFields[$i]->_value;
420 }
421 }
422 return $params;
423 }
424
425 /**
426 * Add progress bar to the import process. Calculates time remaining, status etc.
427 *
428 * @param $statusID
429 * status id of the import process saved in $config->uploadDir.
430 * @param bool $startImport
431 * True when progress bar is to be initiated.
432 * @param $startTimestamp
433 * Initial timestamp when the import was started.
434 * @param $prevTimestamp
435 * Previous timestamp when this function was last called.
436 * @param $totalRowCount
437 * Total number of rows in the import file.
438 *
439 * @return NULL|$currTimestamp
440 */
441 public function progressImport($statusID, $startImport = TRUE, $startTimestamp = NULL, $prevTimestamp = NULL, $totalRowCount = NULL) {
442 $statusFile = CRM_Core_Config::singleton()->uploadDir . "status_{$statusID}.txt";
443
444 if ($startImport) {
445 $status = "<div class='description'>&nbsp; " . ts('No processing status reported yet.') . "</div>";
446 //do not force the browser to display the save dialog, CRM-7640
447 $contents = json_encode([0, $status]);
448 file_put_contents($statusFile, $contents);
449 }
450 else {
451 $rowCount = $this->_rowCount ?? $this->_lineCount;
452 $currTimestamp = time();
453 $time = ($currTimestamp - $prevTimestamp);
454 $recordsLeft = $totalRowCount - $rowCount;
455 if ($recordsLeft < 0) {
456 $recordsLeft = 0;
457 }
458 $estimatedTime = ($recordsLeft / 50) * $time;
459 $estMinutes = floor($estimatedTime / 60);
460 $timeFormatted = '';
461 if ($estMinutes > 1) {
462 $timeFormatted = $estMinutes . ' ' . ts('minutes') . ' ';
463 $estimatedTime = $estimatedTime - ($estMinutes * 60);
464 }
465 $timeFormatted .= round($estimatedTime) . ' ' . ts('seconds');
466 $processedPercent = (int ) (($rowCount * 100) / $totalRowCount);
467 $statusMsg = ts('%1 of %2 records - %3 remaining',
468 [1 => $rowCount, 2 => $totalRowCount, 3 => $timeFormatted]
469 );
470 $status = "<div class=\"description\">&nbsp; <strong>{$statusMsg}</strong></div>";
471 $contents = json_encode([$processedPercent, $status]);
472
473 file_put_contents($statusFile, $contents);
474 return $currTimestamp;
475 }
476 }
477
478 /**
479 * @return array
480 */
481 public function getSelectValues(): array {
482 $values = [];
483 foreach ($this->_fields as $name => $field) {
484 $values[$name] = $field->_title;
485 }
486 return $values;
487 }
488
489 /**
490 * @return array
491 */
492 public function getSelectTypes() {
493 $values = [];
494 // This is only called from the MapField form in isolation now,
495 // so we need to set the metadata.
496 $this->init();
497 foreach ($this->_fields as $name => $field) {
498 if (isset($field->_hasLocationType)) {
499 $values[$name] = $field->_hasLocationType;
500 }
501 }
502 return $values;
503 }
504
505 /**
506 * @return array
507 */
508 public function getHeaderPatterns() {
509 $values = [];
510 foreach ($this->_fields as $name => $field) {
511 if (isset($field->_headerPattern)) {
512 $values[$name] = $field->_headerPattern;
513 }
514 }
515 return $values;
516 }
517
518 /**
519 * @return array
520 */
521 public function getDataPatterns() {
522 $values = [];
523 foreach ($this->_fields as $name => $field) {
524 $values[$name] = $field->_dataPattern;
525 }
526 return $values;
527 }
528
529 /**
530 * Remove single-quote enclosures from a value array (row).
531 *
532 * @param array $values
533 * @param string $enclosure
534 *
535 * @return void
536 */
537 public static function encloseScrub(&$values, $enclosure = "'") {
538 if (empty($values)) {
539 return;
540 }
541
542 foreach ($values as $k => $v) {
543 $values[$k] = preg_replace("/^$enclosure(.*)$enclosure$/", '$1', $v);
544 }
545 }
546
547 /**
548 * Setter function.
549 *
550 * @param int $max
551 *
552 * @return void
553 */
554 public function setMaxLinesToProcess($max) {
555 $this->_maxLinesToProcess = $max;
556 }
557
558 /**
559 * Validate that we have the required fields to create the contact or find it to update.
560 *
561 * Note that the users duplicate selection affects this as follows
562 * - if they did not select an update variant then the id field is not
563 * permitted in the mapping - so we can assume the presence of id means
564 * we should use it
565 * - the external_identifier field is valid in place of the other fields
566 * when they have chosen update or fill - in this case we are only looking
567 * to update an existing contact.
568 *
569 * @param string $contactType
570 * @param array $params
571 * @param bool $isPermitExistingMatchFields
572 * True if the it is enough to have fields which will enable us to find
573 * an existing contact (eg. external_identifier).
574 * @param string $prefixString
575 * String to include in the exception (e.g '(Child of)' if we are validating
576 * a related contact.
577 *
578 * @return void
579 * @throws \CRM_Core_Exception
580 */
581 protected function validateRequiredContactFields(string $contactType, array $params, bool $isPermitExistingMatchFields = TRUE, $prefixString = ''): void {
582 if (!empty($params['id'])) {
583 return;
584 }
585 $requiredFields = [
586 'Individual' => [
587 'first_name_last_name' => ['first_name' => ts('First Name'), 'last_name' => ts('Last Name')],
588 'email' => ts('Email Address'),
589 ],
590 'Organization' => ['organization_name' => ts('Organization Name')],
591 'Household' => ['household_name' => ts('Household Name')],
592 ][$contactType];
593 if ($isPermitExistingMatchFields) {
594 $requiredFields['external_identifier'] = ts('External Identifier');
595 // Historically just an email has been accepted as it is 'usually good enough'
596 // for a dedupe rule look up - but really this is a stand in for
597 // whatever is needed to find an existing matching contact using the
598 // specified dedupe rule (or the default Unsupervised if not specified).
599 $requiredFields['email'] = ts('Email Address');
600 }
601 $this->validateRequiredFields($requiredFields, $params, $prefixString);
602 }
603
604 /**
605 * Determines the file extension based on error code.
606 *
607 * @var int $type error code constant
608 * @return string
609 */
610 public static function errorFileName($type) {
611 $fileName = NULL;
612 if (empty($type)) {
613 return $fileName;
614 }
615
616 $config = CRM_Core_Config::singleton();
617 $fileName = $config->uploadDir . "sqlImport";
618 switch ($type) {
619 case self::ERROR:
620 $fileName .= '.errors';
621 break;
622
623 case self::DUPLICATE:
624 $fileName .= '.duplicates';
625 break;
626
627 case self::NO_MATCH:
628 $fileName .= '.mismatch';
629 break;
630
631 case self::UNPARSED_ADDRESS_WARNING:
632 $fileName .= '.unparsedAddress';
633 break;
634 }
635
636 return $fileName;
637 }
638
639 /**
640 * Determines the file name based on error code.
641 *
642 * @var $type error code constant
643 * @return string
644 */
645 public static function saveFileName($type) {
646 $fileName = NULL;
647 if (empty($type)) {
648 return $fileName;
649 }
650 switch ($type) {
651 case self::ERROR:
652 $fileName = 'Import_Errors.csv';
653 break;
654
655 case self::DUPLICATE:
656 $fileName = 'Import_Duplicates.csv';
657 break;
658
659 case self::NO_MATCH:
660 $fileName = 'Import_Mismatch.csv';
661 break;
662
663 case self::UNPARSED_ADDRESS_WARNING:
664 $fileName = 'Import_Unparsed_Address.csv';
665 break;
666 }
667
668 return $fileName;
669 }
670
671 /**
672 * Check if contact is a duplicate .
673 *
674 * @param array $formatValues
675 *
676 * @return array
677 */
678 protected function checkContactDuplicate(&$formatValues) {
679 //retrieve contact id using contact dedupe rule
680 $formatValues['contact_type'] = $formatValues['contact_type'] ?? $this->_contactType;
681 $formatValues['version'] = 3;
682 require_once 'CRM/Utils/DeprecatedUtils.php';
683 $params = $formatValues;
684 static $cIndieFields = NULL;
685 static $defaultLocationId = NULL;
686
687 $contactType = $params['contact_type'];
688 if ($cIndieFields == NULL) {
689 $cTempIndieFields = CRM_Contact_BAO_Contact::importableFields($contactType);
690 $cIndieFields = $cTempIndieFields;
691
692 $defaultLocation = CRM_Core_BAO_LocationType::getDefault();
693
694 // set the value to default location id else set to 1
695 if (!$defaultLocationId = (int) $defaultLocation->id) {
696 $defaultLocationId = 1;
697 }
698 }
699
700 $locationFields = CRM_Contact_BAO_Query::$_locationSpecificFields;
701
702 $contactFormatted = [];
703 foreach ($params as $key => $field) {
704 if ($field == NULL || $field === '') {
705 continue;
706 }
707 // CRM-17040, Considering only primary contact when importing contributions. So contribution inserts into primary contact
708 // instead of soft credit contact.
709 if (is_array($field) && $key != "soft_credit") {
710 foreach ($field as $value) {
711 $break = FALSE;
712 if (is_array($value)) {
713 foreach ($value as $name => $testForEmpty) {
714 if ($name !== 'phone_type' &&
715 ($testForEmpty === '' || $testForEmpty == NULL)
716 ) {
717 $break = TRUE;
718 break;
719 }
720 }
721 }
722 else {
723 $break = TRUE;
724 }
725 if (!$break) {
726 $this->_civicrm_api3_deprecated_add_formatted_param($value, $contactFormatted);
727 }
728 }
729 continue;
730 }
731
732 $value = [$key => $field];
733
734 // check if location related field, then we need to add primary location type
735 if (in_array($key, $locationFields)) {
736 $value['location_type_id'] = $defaultLocationId;
737 }
738 elseif (array_key_exists($key, $cIndieFields)) {
739 $value['contact_type'] = $contactType;
740 }
741
742 $this->_civicrm_api3_deprecated_add_formatted_param($value, $contactFormatted);
743 }
744
745 $contactFormatted['contact_type'] = $contactType;
746
747 return _civicrm_api3_deprecated_duplicate_formatted_contact($contactFormatted);
748 }
749
750 /**
751 * This function adds the contact variable in $values to the
752 * parameter list $params. For most cases, $values should have length 1. If
753 * the variable being added is a child of Location, a location_type_id must
754 * also be included. If it is a child of phone, a phone_type must be included.
755 *
756 * @param array $values
757 * The variable(s) to be added.
758 * @param array $params
759 * The structured parameter list.
760 *
761 * @return bool|CRM_Utils_Error
762 */
763 private function _civicrm_api3_deprecated_add_formatted_param(&$values, &$params) {
764 // @todo - like most functions in import ... most of this is cruft....
765 // Crawl through the possible classes:
766 // Contact
767 // Individual
768 // Household
769 // Organization
770 // Location
771 // Address
772 // Email
773 // Phone
774 // IM
775 // Note
776 // Custom
777
778 // Cache the various object fields
779 static $fields = NULL;
780
781 if ($fields == NULL) {
782 $fields = [];
783 }
784
785 // first add core contact values since for other Civi modules they are not added
786 require_once 'CRM/Contact/BAO/Contact.php';
787 $contactFields = CRM_Contact_DAO_Contact::fields();
788 _civicrm_api3_store_values($contactFields, $values, $params);
789
790 if (isset($values['contact_type'])) {
791 // we're an individual/household/org property
792
793 $fields[$values['contact_type']] = CRM_Contact_DAO_Contact::fields();
794
795 _civicrm_api3_store_values($fields[$values['contact_type']], $values, $params);
796 return TRUE;
797 }
798
799 if (isset($values['individual_prefix'])) {
800 if (!empty($params['prefix_id'])) {
801 $prefixes = CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'prefix_id');
802 $params['prefix'] = $prefixes[$params['prefix_id']];
803 }
804 else {
805 $params['prefix'] = $values['individual_prefix'];
806 }
807 return TRUE;
808 }
809
810 if (isset($values['individual_suffix'])) {
811 if (!empty($params['suffix_id'])) {
812 $suffixes = CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'suffix_id');
813 $params['suffix'] = $suffixes[$params['suffix_id']];
814 }
815 else {
816 $params['suffix'] = $values['individual_suffix'];
817 }
818 return TRUE;
819 }
820
821 // CRM-4575
822 if (isset($values['email_greeting'])) {
823 if (!empty($params['email_greeting_id'])) {
824 $emailGreetingFilter = [
825 'contact_type' => $params['contact_type'] ?? NULL,
826 'greeting_type' => 'email_greeting',
827 ];
828 $emailGreetings = CRM_Core_PseudoConstant::greeting($emailGreetingFilter);
829 $params['email_greeting'] = $emailGreetings[$params['email_greeting_id']];
830 }
831 else {
832 $params['email_greeting'] = $values['email_greeting'];
833 }
834
835 return TRUE;
836 }
837
838 if (isset($values['postal_greeting'])) {
839 if (!empty($params['postal_greeting_id'])) {
840 $postalGreetingFilter = [
841 'contact_type' => $params['contact_type'] ?? NULL,
842 'greeting_type' => 'postal_greeting',
843 ];
844 $postalGreetings = CRM_Core_PseudoConstant::greeting($postalGreetingFilter);
845 $params['postal_greeting'] = $postalGreetings[$params['postal_greeting_id']];
846 }
847 else {
848 $params['postal_greeting'] = $values['postal_greeting'];
849 }
850 return TRUE;
851 }
852
853 if (isset($values['addressee'])) {
854 $params['addressee'] = $values['addressee'];
855 return TRUE;
856 }
857
858 if (isset($values['gender'])) {
859 if (!empty($params['gender_id'])) {
860 $genders = CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'gender_id');
861 $params['gender'] = $genders[$params['gender_id']];
862 }
863 else {
864 $params['gender'] = $values['gender'];
865 }
866 return TRUE;
867 }
868
869 if (!empty($values['preferred_communication_method'])) {
870 $comm = [];
871 $pcm = array_change_key_case(array_flip(CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'preferred_communication_method')), CASE_LOWER);
872
873 $preffComm = explode(',', $values['preferred_communication_method']);
874 foreach ($preffComm as $v) {
875 $v = strtolower(trim($v));
876 if (array_key_exists($v, $pcm)) {
877 $comm[$pcm[$v]] = 1;
878 }
879 }
880
881 $params['preferred_communication_method'] = $comm;
882 return TRUE;
883 }
884
885 // format the website params.
886 if (!empty($values['url'])) {
887 static $websiteFields;
888 if (!is_array($websiteFields)) {
889 require_once 'CRM/Core/DAO/Website.php';
890 $websiteFields = CRM_Core_DAO_Website::fields();
891 }
892 if (!array_key_exists('website', $params) ||
893 !is_array($params['website'])
894 ) {
895 $params['website'] = [];
896 }
897
898 $websiteCount = count($params['website']);
899 _civicrm_api3_store_values($websiteFields, $values,
900 $params['website'][++$websiteCount]
901 );
902
903 return TRUE;
904 }
905
906 // get the formatted location blocks into params - w/ 3.0 format, CRM-4605
907 if (!empty($values['location_type_id'])) {
908 static $fields = NULL;
909 if ($fields == NULL) {
910 $fields = [];
911 }
912
913 foreach (['Phone', 'Email', 'IM', 'OpenID', 'Phone_Ext'] as $block) {
914 $name = strtolower($block);
915 if (!array_key_exists($name, $values)) {
916 continue;
917 }
918
919 if ($name === 'phone_ext') {
920 $block = 'Phone';
921 }
922
923 // block present in value array.
924 if (!array_key_exists($name, $params) || !is_array($params[$name])) {
925 $params[$name] = [];
926 }
927
928 if (!array_key_exists($block, $fields)) {
929 $className = "CRM_Core_DAO_$block";
930 $fields[$block] =& $className::fields();
931 }
932
933 $blockCnt = count($params[$name]);
934
935 // copy value to dao field name.
936 if ($name == 'im') {
937 $values['name'] = $values[$name];
938 }
939
940 _civicrm_api3_store_values($fields[$block], $values,
941 $params[$name][++$blockCnt]
942 );
943
944 if (empty($params['id']) && ($blockCnt == 1)) {
945 $params[$name][$blockCnt]['is_primary'] = TRUE;
946 }
947
948 // we only process single block at a time.
949 return TRUE;
950 }
951
952 // handle address fields.
953 if (!array_key_exists('address', $params) || !is_array($params['address'])) {
954 $params['address'] = [];
955 }
956
957 $addressCnt = 1;
958 foreach ($params['address'] as $cnt => $addressBlock) {
959 if (CRM_Utils_Array::value('location_type_id', $values) ==
960 CRM_Utils_Array::value('location_type_id', $addressBlock)
961 ) {
962 $addressCnt = $cnt;
963 break;
964 }
965 $addressCnt++;
966 }
967
968 if (!array_key_exists('Address', $fields)) {
969 $fields['Address'] = CRM_Core_DAO_Address::fields();
970 }
971
972 // Note: we doing multiple value formatting here for address custom fields, plus putting into right format.
973 // The actual formatting (like date, country ..etc) for address custom fields is taken care of while saving
974 // the address in CRM_Core_BAO_Address::create method
975 if (!empty($values['location_type_id'])) {
976 static $customFields = [];
977 if (empty($customFields)) {
978 $customFields = CRM_Core_BAO_CustomField::getFields('Address');
979 }
980 // make a copy of values, as we going to make changes
981 $newValues = $values;
982 foreach ($values as $key => $val) {
983 $customFieldID = CRM_Core_BAO_CustomField::getKeyID($key);
984 if ($customFieldID && array_key_exists($customFieldID, $customFields)) {
985 // mark an entry in fields array since we want the value of custom field to be copied
986 $fields['Address'][$key] = NULL;
987
988 $htmlType = $customFields[$customFieldID]['html_type'] ?? NULL;
989 if (CRM_Core_BAO_CustomField::isSerialized($customFields[$customFieldID]) && $val) {
990 $mulValues = explode(',', $val);
991 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
992 $newValues[$key] = [];
993 foreach ($mulValues as $v1) {
994 foreach ($customOption as $v2) {
995 if ((strtolower($v2['label']) == strtolower(trim($v1))) ||
996 (strtolower($v2['value']) == strtolower(trim($v1)))
997 ) {
998 if ($htmlType == 'CheckBox') {
999 $newValues[$key][$v2['value']] = 1;
1000 }
1001 else {
1002 $newValues[$key][] = $v2['value'];
1003 }
1004 }
1005 }
1006 }
1007 }
1008 }
1009 }
1010 // consider new values
1011 $values = $newValues;
1012 }
1013
1014 _civicrm_api3_store_values($fields['Address'], $values, $params['address'][$addressCnt]);
1015
1016 $addressFields = [
1017 'county',
1018 'country',
1019 'state_province',
1020 'supplemental_address_1',
1021 'supplemental_address_2',
1022 'supplemental_address_3',
1023 'StateProvince.name',
1024 ];
1025
1026 foreach ($addressFields as $field) {
1027 if (array_key_exists($field, $values)) {
1028 if (!array_key_exists('address', $params)) {
1029 $params['address'] = [];
1030 }
1031 $params['address'][$addressCnt][$field] = $values[$field];
1032 }
1033 }
1034
1035 if ($addressCnt == 1) {
1036
1037 $params['address'][$addressCnt]['is_primary'] = TRUE;
1038 }
1039 return TRUE;
1040 }
1041
1042 if (isset($values['note'])) {
1043 // add a note field
1044 if (!isset($params['note'])) {
1045 $params['note'] = [];
1046 }
1047 $noteBlock = count($params['note']) + 1;
1048
1049 $params['note'][$noteBlock] = [];
1050 if (!isset($fields['Note'])) {
1051 $fields['Note'] = CRM_Core_DAO_Note::fields();
1052 }
1053
1054 // get the current logged in civicrm user
1055 $session = CRM_Core_Session::singleton();
1056 $userID = $session->get('userID');
1057
1058 if ($userID) {
1059 $values['contact_id'] = $userID;
1060 }
1061
1062 _civicrm_api3_store_values($fields['Note'], $values, $params['note'][$noteBlock]);
1063
1064 return TRUE;
1065 }
1066
1067 // Check for custom field values
1068
1069 if (empty($fields['custom'])) {
1070 $fields['custom'] = &CRM_Core_BAO_CustomField::getFields(CRM_Utils_Array::value('contact_type', $values),
1071 FALSE, FALSE, NULL, NULL, FALSE, FALSE, FALSE
1072 );
1073 }
1074
1075 foreach ($values as $key => $value) {
1076 if ($customFieldID = CRM_Core_BAO_CustomField::getKeyID($key)) {
1077 // check if it's a valid custom field id
1078
1079 if (!array_key_exists($customFieldID, $fields['custom'])) {
1080 return civicrm_api3_create_error('Invalid custom field ID');
1081 }
1082 else {
1083 $params[$key] = $value;
1084 }
1085 }
1086 }
1087 }
1088
1089 /**
1090 * Parse a field which could be represented by a label or name value rather than the DB value.
1091 *
1092 * We will try to match name first or (per https://lab.civicrm.org/dev/core/issues/1285 if we have an id.
1093 *
1094 * but if not available then see if we have a label that can be converted to a name.
1095 *
1096 * @param string|int|null $submittedValue
1097 * @param array $fieldSpec
1098 * Metadata for the field
1099 *
1100 * @return mixed
1101 */
1102 protected function parsePseudoConstantField($submittedValue, $fieldSpec) {
1103 // 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
1104 if (!isset($fieldSpec['bao'])) {
1105 return $submittedValue;
1106 }
1107 /* @var \CRM_Core_DAO $bao */
1108 $bao = $fieldSpec['bao'];
1109 // For historical reasons use validate as context - ie disabled name matches ARE permitted.
1110 $nameOptions = $bao::buildOptions($fieldSpec['name'], 'validate');
1111 if (isset($nameOptions[$submittedValue])) {
1112 return $submittedValue;
1113 }
1114 if (in_array($submittedValue, $nameOptions)) {
1115 return array_search($submittedValue, $nameOptions, TRUE);
1116 }
1117
1118 $labelOptions = array_flip($bao::buildOptions($fieldSpec['name'], 'match'));
1119 if (isset($labelOptions[$submittedValue])) {
1120 return array_search($labelOptions[$submittedValue], $nameOptions, TRUE);
1121 }
1122 return '';
1123 }
1124
1125 /**
1126 * This is code extracted from 4 places where this exact snippet was being duplicated.
1127 *
1128 * FIXME: Extracting this was a first step, but there's also
1129 * 1. Inconsistency in the way other select options are handled.
1130 * Contribution adds handling for Select/Radio/Autocomplete
1131 * Participant/Activity only handles Select/Radio and misses Autocomplete
1132 * Membership is missing all of it
1133 * 2. Inconsistency with the way this works vs. how it's implemented in Contact import.
1134 *
1135 * @param $customFieldID
1136 * @param $value
1137 * @param $fieldType
1138 * @return array
1139 */
1140 public static function unserializeCustomValue($customFieldID, $value, $fieldType) {
1141 $mulValues = explode(',', $value);
1142 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
1143 $values = [];
1144 foreach ($mulValues as $v1) {
1145 foreach ($customOption as $customValueID => $customLabel) {
1146 $customValue = $customLabel['value'];
1147 if ((strtolower(trim($customLabel['label'])) == strtolower(trim($v1))) ||
1148 (strtolower(trim($customValue)) == strtolower(trim($v1)))
1149 ) {
1150 $values[] = $customValue;
1151 }
1152 }
1153 }
1154 return $values;
1155 }
1156
1157 /**
1158 * Validate that the field requirements are met in the params.
1159 *
1160 * @param array $requiredFields
1161 * @param array $params
1162 * An array of required fields (fieldName => label)
1163 * - note this follows the and / or array nesting we see in permission checks
1164 * eg.
1165 * [
1166 * 'email' => ts('Email'),
1167 * ['first_name' => ts('First Name'), 'last_name' => ts('Last Name')]
1168 * ]
1169 * Means 'email' OR 'first_name AND 'last_name'.
1170 * @param string $prefixString
1171 *
1172 * @throws \CRM_Core_Exception Exception thrown if field requirements are not met.
1173 */
1174 protected function validateRequiredFields(array $requiredFields, array $params, $prefixString): void {
1175 $missingFields = [];
1176 foreach ($requiredFields as $key => $required) {
1177 if (!is_array($required)) {
1178 $importParameter = $params[$key] ?? [];
1179 if (!is_array($importParameter)) {
1180 if (!empty($importParameter)) {
1181 return;
1182 }
1183 }
1184 else {
1185 foreach ($importParameter as $locationValues) {
1186 if (!empty($locationValues[$key])) {
1187 return;
1188 }
1189 }
1190 }
1191
1192 $missingFields[$key] = $required;
1193 }
1194 else {
1195 foreach ($required as $field => $label) {
1196 if (empty($params[$field])) {
1197 $missing[$field] = $label;
1198 }
1199 }
1200 if (empty($missing)) {
1201 return;
1202 }
1203 $missingFields[$key] = implode(' ' . ts('and') . ' ', $missing);
1204 }
1205 }
1206 throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields));
1207 }
1208
1209 /**
1210 * Get the field value, transformed by metadata.
1211 *
1212 * @param string $fieldName
1213 * @param string|int $importedValue
1214 * Value as it came in from the datasource.
1215 *
1216 * @return string|array|bool|int
1217 * @throws \API_Exception
1218 */
1219 protected function getTransformedFieldValue(string $fieldName, $importedValue) {
1220 $transformableFields = array_merge($this->metadataHandledFields, ['country_id']);
1221 // For now only do gender_id etc as we need to work through removing duplicate handling
1222 if (empty($importedValue) || !in_array($fieldName, $transformableFields, TRUE)) {
1223 return $importedValue;
1224 }
1225 $fieldMetadata = $this->getFieldMetadata($fieldName);
1226 if ($fieldName === 'url') {
1227 return CRM_Utils_Rule::url($importedValue) ? $importedValue : 'invalid_import_value';
1228 }
1229
1230 if ($fieldName === 'email') {
1231 return CRM_Utils_Rule::email($importedValue) ? $importedValue : 'invalid_import_value';
1232 }
1233
1234 if ($fieldMetadata['type'] === CRM_Utils_Type::T_BOOLEAN) {
1235 $value = CRM_Utils_String::strtoboolstr($importedValue);
1236 if ($value !== FALSE) {
1237 return (bool) $value;
1238 }
1239 return 'invalid_import_value';
1240 }
1241 if ($fieldMetadata['type'] === CRM_Utils_Type::T_DATE) {
1242 $value = CRM_Utils_Date::formatDate($importedValue, $this->getSubmittedValue('dateFormats'));
1243 return ($value) ?: 'invalid_import_value';
1244 }
1245 $options = $this->getFieldOptions($fieldName);
1246 if ($options !== FALSE) {
1247 $comparisonValue = is_numeric($importedValue) ? $importedValue : mb_strtolower($importedValue);
1248 return $options[$comparisonValue] ?? 'invalid_import_value';
1249 }
1250 return $importedValue;
1251 }
1252
1253 /**
1254 * @param string $fieldName
1255 *
1256 * @return false|array
1257 *
1258 * @throws \API_Exception
1259 */
1260 protected function getFieldOptions(string $fieldName) {
1261 return $this->getFieldMetadata($fieldName, TRUE)['options'];
1262 }
1263
1264 /**
1265 * Get the metadata for the field.
1266 *
1267 * @param string $fieldName
1268 * @param bool $loadOptions
1269 * @param bool $limitToContactType
1270 * Only show fields for the type to import (not appropriate when looking up
1271 * related contact fields).
1272 *
1273 *
1274 * @return array
1275 * @throws \API_Exception
1276 * @throws \Civi\API\Exception\NotImplementedException
1277 */
1278 protected function getFieldMetadata(string $fieldName, bool $loadOptions = FALSE, $limitToContactType = FALSE): array {
1279
1280 $fieldMap = ['country_id' => 'country'];
1281 $fieldMapName = empty($fieldMap[$fieldName]) ? $fieldName : $fieldMap[$fieldName];
1282
1283 $fieldMetadata = $this->getImportableFieldsMetadata()[$fieldMapName] ?? ($limitToContactType ? NULL : CRM_Contact_BAO_Contact::importableFields('All')[$fieldMapName]);
1284 if ($loadOptions && !isset($fieldMetadata['options'])) {
1285
1286 $options = civicrm_api4($this->getFieldEntity($fieldName), 'getFields', [
1287 'loadOptions' => ['id', 'name', 'label'],
1288 'where' => [['name', '=', empty($fieldMap[$fieldName]) ? $fieldMetadata['name'] : $fieldName]],
1289 'select' => ['options'],
1290 ])->first()['options'];
1291 if (is_array($options)) {
1292 // We create an array of the possible variants - notably including
1293 // name AND label as either might be used. We also lower case before checking
1294 $values = [];
1295 foreach ($options as $option) {
1296 $values[$option['id']] = $option['id'];
1297 $values[mb_strtolower($option['name'])] = $option['id'];
1298 $values[mb_strtolower($option['label'])] = $option['id'];
1299 }
1300 $this->importableFieldsMetadata[$fieldMapName]['options'] = $values;
1301 }
1302 else {
1303 $this->importableFieldsMetadata[$fieldMapName]['options'] = $options;
1304 }
1305 return $this->importableFieldsMetadata[$fieldMapName];
1306 }
1307 return $fieldMetadata;
1308 }
1309
1310 /**
1311 * @param $customFieldID
1312 * @param $value
1313 * @param array $fieldMetaData
1314 * @param $dateType
1315 *
1316 * @return ?string
1317 */
1318 protected function validateCustomField($customFieldID, $value, array $fieldMetaData, $dateType): ?string {
1319 /* validate the data against the CF type */
1320
1321 if ($value) {
1322 $dataType = $fieldMetaData['data_type'];
1323 $htmlType = $fieldMetaData['html_type'];
1324 $isSerialized = CRM_Core_BAO_CustomField::isSerialized($fieldMetaData);
1325 if ($dataType === 'Date') {
1326 $params = ['date_field' => $value];
1327 if (CRM_Utils_Date::convertToDefaultDate($params, $dateType, 'date_field')) {
1328 return NULL;
1329 }
1330 return $fieldMetaData['label'];
1331 }
1332 elseif ($dataType === 'Boolean') {
1333 if (CRM_Utils_String::strtoboolstr($value) === FALSE) {
1334 return $fieldMetaData['label'] . '::' . $fieldMetaData['groupTitle'];
1335 }
1336 }
1337 // need not check for label filed import
1338 $selectHtmlTypes = [
1339 'CheckBox',
1340 'Select',
1341 'Radio',
1342 ];
1343 if ((!$isSerialized && !in_array($htmlType, $selectHtmlTypes)) || $dataType == 'Boolean' || $dataType == 'ContactReference') {
1344 $valid = CRM_Core_BAO_CustomValue::typecheck($dataType, $value);
1345 if (!$valid) {
1346 return $fieldMetaData['label'];
1347 }
1348 }
1349
1350 // check for values for custom fields for checkboxes and multiselect
1351 if ($isSerialized && $dataType != 'ContactReference') {
1352 $mulValues = array_filter(explode(',', str_replace('|', ',', trim($value))), 'strlen');
1353 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
1354 foreach ($mulValues as $v1) {
1355
1356 $flag = FALSE;
1357 foreach ($customOption as $v2) {
1358 if ((strtolower(trim($v2['label'])) == strtolower(trim($v1))) || (strtolower(trim($v2['value'])) == strtolower(trim($v1)))) {
1359 $flag = TRUE;
1360 }
1361 }
1362
1363 if (!$flag) {
1364 return $fieldMetaData['label'];
1365 }
1366 }
1367 }
1368 elseif ($htmlType == 'Select' || ($htmlType == 'Radio' && $dataType != 'Boolean')) {
1369 $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
1370 $flag = FALSE;
1371 foreach ($customOption as $v2) {
1372 if ((strtolower(trim($v2['label'])) == strtolower(trim($value))) || (strtolower(trim($v2['value'])) == strtolower(trim($value)))) {
1373 $flag = TRUE;
1374 }
1375 }
1376 if (!$flag) {
1377 return $fieldMetaData['label'];
1378 }
1379 }
1380 }
1381
1382 return NULL;
1383 }
1384
1385 /**
1386 * Get the entity for the given field.
1387 *
1388 * @param string $fieldName
1389 *
1390 * @return mixed|null
1391 * @throws \API_Exception
1392 */
1393 protected function getFieldEntity(string $fieldName) {
1394 if ($fieldName === 'do_not_import') {
1395 return NULL;
1396 }
1397 $metadata = $this->getFieldMetadata($fieldName);
1398 if (!isset($metadata['entity'])) {
1399 return in_array($metadata['extends'], ['Individual', 'Organization', 'Household'], TRUE) ? 'Contact' : $metadata['extends'];
1400 }
1401
1402 // Our metadata for these is fugly. Handling the fugliness during retrieval.
1403 if (in_array($metadata['entity'], ['Country', 'StateProvince', 'County'], TRUE)) {
1404 return 'Address';
1405 }
1406 return $metadata['entity'];
1407 }
1408
1409 /**
1410 * Search the value for the string 'invalid_import_value'.
1411 *
1412 * If the string is found it indicates the fields was rejected
1413 * during `getTransformedValue` as not having valid data.
1414 *
1415 * @param string|array|int $value
1416 * @param string $key
1417 * @param string $prefixString
1418 *
1419 * @return array
1420 * @throws \API_Exception
1421 */
1422 protected function getInvalidValues($value, string $key, string $prefixString = ''): array {
1423 $errors = [];
1424 if ($value === 'invalid_import_value') {
1425 $errors[] = $prefixString . $this->getFieldMetadata($key)['title'];
1426 }
1427 elseif (is_array($value)) {
1428 foreach ($value as $innerKey => $innerValue) {
1429 $result = $this->getInvalidValues($innerValue, $innerKey, $prefixString);
1430 if (!empty($result)) {
1431 $errors = array_merge($result, $errors);
1432 }
1433 }
1434 }
1435 return array_filter($errors);
1436 }
1437
1438 /**
1439 * Get the available countries.
1440 *
1441 * If the site is not configured with a restriction then all countries are valid
1442 * but otherwise only a select array are.
1443 *
1444 * @return array|false
1445 * FALSE indicates no restrictions.
1446 */
1447 protected function getAvailableCountries() {
1448 if ($this->availableCountries === NULL) {
1449 $availableCountries = Civi::settings()->get('countryLimit');
1450 $this->availableCountries = !empty($availableCountries) ? array_fill_keys($availableCountries, TRUE) : FALSE;
1451 }
1452 return $this->availableCountries;
1453 }
1454
1455}