| +-----------------------------------------------------------------------+ */ /** * CSV to vCard data converter */ class rcube_csv2vcard { /** * CSV to vCard fields mapping * * @var array */ protected $csv2vcard_map = [ // MS Outlook 2010 'anniversary' => 'anniversary', 'assistants_name' => 'assistant', 'assistants_phone' => 'phone:assistant', 'birthday' => 'birthday', 'business_city' => 'locality:work', 'business_countryregion' => 'country:work', 'business_fax' => 'phone:work,fax', 'business_phone' => 'phone:work', 'business_phone_2' => 'phone:work2', 'business_postal_code' => 'zipcode:work', 'business_state' => 'region:work', 'business_street' => 'street:work', // 'business_street_2' => '', // 'business_street_3' => '', 'car_phone' => 'phone:car', 'categories' => 'groups', // 'children' => '', 'company' => 'organization', // 'company_main_phone' => '', 'department' => 'department', 'email_2_address' => 'email:other', // 'email_2_type' => '', 'email_3_address' => 'email:other', // 'email_3_type' => '', 'email_address' => 'email:pref', // 'email_type' => '', 'first_name' => 'firstname', 'gender' => 'gender', 'home_city' => 'locality:home', 'home_countryregion' => 'country:home', 'home_fax' => 'phone:home,fax', 'home_phone' => 'phone:home', 'home_phone_2' => 'phone:home2', 'home_postal_code' => 'zipcode:home', 'home_state' => 'region:home', 'home_street' => 'street:home', // 'home_street_2' => '', // 'home_street_3' => '', // 'initials' => '', // 'isdn' => '', 'job_title' => 'jobtitle', // 'keywords' => '', // 'language' => '', 'last_name' => 'surname', // 'location' => '', 'managers_name' => 'manager', 'middle_name' => 'middlename', // 'mileage' => '', 'mobile_phone' => 'phone:cell', 'notes' => 'notes', // 'office_location' => '', 'other_city' => 'locality:other', 'other_countryregion' => 'country:other', 'other_fax' => 'phone:other,fax', 'other_phone' => 'phone:other', 'other_postal_code' => 'zipcode:other', 'other_state' => 'region:other', 'other_street' => 'street:other', // 'other_street_2' => '', // 'other_street_3' => '', 'pager' => 'phone:pager', 'primary_phone' => 'phone:pref', // 'profession' => '', // 'radio_phone' => '', 'spouse' => 'spouse', 'suffix' => 'suffix', 'title' => 'title', 'web_page' => 'website:homepage', // Thunderbird 'birth_day' => 'birthday-d', 'birth_month' => 'birthday-m', 'birth_year' => 'birthday-y', 'display_name' => 'displayname', 'fax_number' => 'phone:fax', 'home_address' => 'street:home', // 'home_address_2' => '', 'home_country' => 'country:home', 'home_zipcode' => 'zipcode:home', 'mobile_number' => 'phone:cell', 'nickname' => 'nickname', 'organization' => 'organization', 'pager_number' => 'phone:pager', 'primary_email' => 'email:pref', 'secondary_email' => 'email:other', 'web_page_1' => 'website:homepage', 'web_page_2' => 'website:other', 'work_phone' => 'phone:work', 'work_address' => 'street:work', // 'work_address_2' => '', 'work_country' => 'country:work', 'work_zipcode' => 'zipcode:work', 'last' => 'surname', 'first' => 'firstname', 'work_city' => 'locality:work', 'work_state' => 'region:work', 'home_city_short' => 'locality:home', 'home_state_short' => 'region:home', // Atmail 'date_of_birth' => 'birthday', // 'email' => 'email:pref', 'home_mobile' => 'phone:cell', 'home_zip' => 'zipcode:home', 'info' => 'notes', 'user_photo' => 'photo', 'url' => 'website:homepage', 'work_company' => 'organization', 'work_dept' => 'department', 'work_fax' => 'phone:work,fax', 'work_mobile' => 'phone:work,cell', 'work_title' => 'jobtitle', 'work_zip' => 'zipcode:work', 'group' => 'groups', // GMail 'groups' => 'groups', 'group_membership' => 'groups', 'given_name' => 'firstname', 'additional_name' => 'middlename', 'family_name' => 'surname', 'name' => 'displayname', 'name_prefix' => 'prefix', 'name_suffix' => 'suffix', // Format of Letter Hub test files from // https://letterhub.com/sample-csv-file-with-contacts/ 'company_name' => 'organization', 'address' => 'street:home', 'city' => 'locality:home', // 'county' => '', 'state' => 'region:home', 'zip' => 'zipcode:home', 'phone1' => 'phone:home', 'phone' => 'phone:work', 'email' => 'email:home', ]; /** * CSV label to text mapping for English * * @var array */ protected $label_map = [ // MS Outlook 2010 'anniversary' => 'Anniversary', 'assistants_name' => "Assistant's Name", 'assistants_phone' => "Assistant's Phone", 'birthday' => 'Birthday', 'business_city' => 'Business City', 'business_countryregion' => 'Business Country/Region', 'business_fax' => 'Business Fax', 'business_phone' => 'Business Phone', 'business_phone_2' => 'Business Phone 2', 'business_postal_code' => 'Business Postal Code', 'business_state' => 'Business State', 'business_street' => 'Business Street', // 'business_street_2' => "Business Street 2", // 'business_street_3' => "Business Street 3", 'car_phone' => 'Car Phone', 'categories' => 'Categories', // 'children' => "Children", 'company' => 'Company', // 'company_main_phone' => "Company Main Phone", 'department' => 'Department', // 'directory_server' => "Directory Server", 'email_2_address' => 'E-mail 2 Address', // 'email_2_type' => "E-mail 2 Type", 'email_3_address' => 'E-mail 3 Address', // 'email_3_type' => "E-mail 3 Type", 'email_address' => 'E-mail Address', // 'email_type' => "E-mail Type", 'first_name' => 'First Name', 'gender' => 'Gender', 'home_city' => 'Home City', 'home_countryregion' => 'Home Country/Region', 'home_fax' => 'Home Fax', 'home_phone' => 'Home Phone', 'home_phone_2' => 'Home Phone 2', 'home_postal_code' => 'Home Postal Code', 'home_state' => 'Home State', 'home_street' => 'Home Street', // 'home_street_2' => "Home Street 2", // 'home_street_3' => "Home Street 3", // 'initials' => "Initials", // 'isdn' => "ISDN", 'job_title' => 'Job Title', // 'keywords' => "Keywords", // 'language' => "Language", 'last_name' => 'Last Name', // 'location' => "Location", 'managers_name' => "Manager's Name", 'middle_name' => 'Middle Name', // 'mileage' => "Mileage", 'mobile_phone' => 'Mobile Phone', 'notes' => 'Notes', // 'office_location' => "Office Location", 'other_city' => 'Other City', 'other_countryregion' => 'Other Country/Region', 'other_fax' => 'Other Fax', 'other_phone' => 'Other Phone', 'other_postal_code' => 'Other Postal Code', 'other_state' => 'Other State', 'other_street' => 'Other Street', // 'other_street_2' => "Other Street 2", // 'other_street_3' => "Other Street 3", 'pager' => 'Pager', 'primary_phone' => 'Primary Phone', // 'profession' => "Profession", // 'radio_phone' => "Radio Phone", 'spouse' => 'Spouse', 'suffix' => 'Suffix', 'title' => 'Title', 'web_page' => 'Web Page', // Thunderbird 'birth_day' => 'Birth Day', 'birth_month' => 'Birth Month', 'birth_year' => 'Birth Year', 'display_name' => 'Display Name', 'fax_number' => 'Fax Number', 'home_address' => 'Home Address', // 'home_address_2' => "Home Address 2", 'home_country' => 'Home Country', 'home_zipcode' => 'Home ZipCode', 'mobile_number' => 'Mobile Number', 'nickname' => 'Nickname', 'organization' => 'Organization', 'pager_number' => 'Pager Number', 'primary_email' => 'Primary Email', 'secondary_email' => 'Secondary Email', 'web_page_1' => 'Web Page 1', 'web_page_2' => 'Web Page 2', 'work_phone' => 'Work Phone', 'work_address' => 'Work Address', // 'work_address_2' => "Work Address 2", 'work_city' => 'Work City', 'work_country' => 'Work Country', 'work_state' => 'Work State', 'work_zipcode' => 'Work ZipCode', // Atmail 'date_of_birth' => 'Date of Birth', 'email' => 'Email', // 'email_2' => "Email2", // 'email_3' => "Email3", // 'email_4' => "Email4", // 'email_5' => "Email5", 'home_mobile' => 'Home Mobile', 'home_zip' => 'Home Zip', 'info' => 'Info', 'user_photo' => 'User Photo', 'url' => 'URL', 'work_company' => 'Work Company', 'work_dept' => 'Work Dept', 'work_fax' => 'Work Fax', 'work_mobile' => 'Work Mobile', 'work_title' => 'Work Title', 'work_zip' => 'Work Zip', 'group' => 'Group', // GMail 'groups' => 'Groups', 'group_membership' => 'Group Membership', 'given_name' => 'Given Name', 'additional_name' => 'Additional Name', 'family_name' => 'Family Name', 'name' => 'Name', 'name_prefix' => 'Name Prefix', 'name_suffix' => 'Name Suffix', ]; /** * Special fields map for GMail format * * @var array */ protected $gmail_label_map = [ 'E-mail' => [ 'Value' => [ 'home' => 'email:home', 'work' => 'email:work', 'other' => 'email:other', '' => 'email:other', ], ], 'Phone' => [ 'Value' => [ 'home' => 'phone:home', 'homefax' => 'phone:homefax', 'main' => 'phone:pref', 'pager' => 'phone:pager', 'mobile' => 'phone:cell', 'work' => 'phone:work', 'workfax' => 'phone:workfax', ], ], 'Relation' => [ 'Value' => [ 'spouse' => 'spouse', ], ], 'Website' => [ 'Value' => [ 'profile' => 'website:profile', 'blog' => 'website:blog', 'homepage' => 'website:homepage', 'work' => 'website:work', ], ], 'Address' => [ 'Street' => [ 'home' => 'street:home', 'work' => 'street:work', ], 'City' => [ 'home' => 'locality:home', 'work' => 'locality:work', ], 'Region' => [ 'home' => 'region:home', 'work' => 'region:work', ], 'Postal Code' => [ 'home' => 'zipcode:home', 'work' => 'zipcode:work', ], 'Country' => [ 'home' => 'country:home', 'work' => 'country:work', ], ], 'Organization' => [ 'Name' => [ '' => 'organization', ], 'Title' => [ '' => 'jobtitle', ], 'Department' => [ '' => 'department', ], ], ]; /** @var array Localized labels map */ protected $local_label_map = []; /** @var rcube_vcard[] List of contacts as vCards */ protected $vcards = []; /** @var array Field mapping */ protected $map = []; /** * Class constructor * * @param string $lang File language */ public function __construct($lang = 'en_US') { // Localize fields map if ($lang && $lang != 'en_US') { $map = null; if (file_exists(RCUBE_LOCALIZATION_DIR . "{$lang}/csv2vcard.inc")) { include RCUBE_LOCALIZATION_DIR . "{$lang}/csv2vcard.inc"; } // @phpstan-ignore-next-line if (!empty($map)) { $this->local_label_map = array_merge($this->label_map, $map); } } } /** * Import contacts from CSV file * * @param string $csv Content of the CSV file * @param bool $dry_run Generate automatic field mapping * @param bool $skip_head Skip header line * * @return array|null Field mapping info (dry run only) */ public function import($csv, $dry_run = false, $skip_head = true) { // convert to UTF-8 (supports default_charset and RCUBE_CHARSET as input) // TODO: If the input charset is invalid we should probably just abort here if ($charset = rcube_charset::check($csv)) { $csv = rcube_charset::convert($csv, $charset); } $csv = preg_replace(['/^[\xFE\xFF]{2}/', '/^\xEF\xBB\xBF/', '/^\x00+/'], '', $csv); // also remove BOM // Split CSV file into lines $lines = rcube_utils::explode_quoted_string('[\r\n]+', $csv); // Parse first 2 lines of file to identify fields // 2 lines because for gmail CSV we need to get the value from the "Type" fields to identify which is which if (empty($this->map)) { $this->parse_header(array_slice($lines, 0, 2)); } // Parse the fields foreach ($lines as $n => $line) { $elements = $this->parse_line($line); if ($dry_run) { return ['source' => $elements, 'destination' => $this->map]; } if (empty($elements)) { continue; } // first line is the headers so do not import unless explicitly set if (!$skip_head || $n > 0) { $this->csv_to_vcard($elements); } } return null; } /** * Set field mapping info * * @param array $elements Field mapping */ public function set_map($elements) { // sanitize input $elements = array_filter($elements, function ($val) { return in_array($val, $this->csv2vcard_map); }); $this->map = $elements; } /** * Set field mapping info * * @return array Array of vcard fields and localized names */ public function get_fields() { // get all vcard fields $fields = array_unique($this->csv2vcard_map); $local_field_names = $this->local_label_map ?: $this->label_map; // translate with the local map $map = []; foreach ($fields as $csv => $vcard) { if ($vcard == '_auto_') { continue; } $map[$vcard] = $local_field_names[$csv]; } // small fix to prevent "Groups" displaying as "Categories" $map['groups'] = $local_field_names['groups']; asort($map); return $map; } /** * Export vCards * * @return array rcube_vcard List of vcards */ public function export() { return $this->vcards; } /** * Parse CSV file line * * @param string $line Line of text from CSV file * * @return array CSV data extracted from the line */ protected function parse_line($line) { $line = trim($line); if (empty($line)) { return []; } $fields = str_getcsv($line, ',', '"', '\\'); return $fields; } /** * Parse CSV header line, detect fields mapping * * @param array $lines One or two header lines in CSV file */ protected function parse_header($lines) { $elements = $this->parse_line($lines[0]); $label_map = array_flip($this->label_map); $local_label_map = array_flip($this->local_label_map); if (count($lines) == 2) { // first line of contents needed to properly identify fields in gmail CSV $contents = $this->parse_line($lines[1]); } $map1 = []; $map2 = []; $size = count($elements); // check English labels for ($i = 0; $i < $size; $i++) { if (!empty($label_map[$elements[$i]])) { $label = $label_map[$elements[$i]]; if (!empty($this->csv2vcard_map[$label])) { $map1[$i] = $this->csv2vcard_map[$label]; } } } // check localized labels if (!empty($local_label_map)) { for ($i = 0; $i < $size; $i++) { $label = $local_label_map[$elements[$i]]; // special localization label if ($label && $label[0] == '_') { $label = substr($label, 1); } if ($label && !empty($this->csv2vcard_map[$label])) { $map2[$i] = $this->csv2vcard_map[$label]; } } } // If nothing recognized fallback to simple non-localized labels if (empty($map1) && empty($map2)) { for ($i = 0; $i < $size; $i++) { $label = str_replace(' ', '_', strtolower($elements[$i])); if (!empty($this->csv2vcard_map[$label])) { $map1[$i] = $this->csv2vcard_map[$label]; } } } $this->map = count($map1) >= count($map2) ? $map1 : $map2; if (!empty($contents)) { foreach ($this->gmail_label_map as $key => $items) { $num = 1; // @phpstan-ignore-next-line while (($_key = "{$key} {$num} - Type") && ($found = array_search($_key, $elements)) !== false) { $type = $contents[$found]; $type = preg_replace('/[^a-z]/', '', strtolower($type)); foreach ($items as $item_key => $vcard_fields) { $_key = "{$key} {$num} - {$item_key}"; if (($found = array_search($_key, $elements)) !== false) { $this->map[$found] = $vcard_fields[$type]; } } $num++; } } } } /** * Convert CSV data row to vCard * * @param array $data CSV data array */ protected function csv_to_vcard($data) { $contact = []; foreach ($this->map as $idx => $name) { if ($name == '_auto_') { continue; } $value = $data[$idx]; if ($value !== null && $value !== '') { if (!empty($contact[$name])) { $contact[$name] = (array) $contact[$name]; $contact[$name][] = $value; } else { $contact[$name] = $value; } } } if (empty($contact)) { return; } // Handle special values if (!empty($contact['birthday-d']) && !empty($contact['birthday-m']) && !empty($contact['birthday-y'])) { $contact['birthday'] = $contact['birthday-y'] . '-' . $contact['birthday-m'] . '-' . $contact['birthday-d']; } if (!empty($contact['groups'])) { // categories/groups separator in vCard is ',' not ';' $contact['groups'] = str_replace(';', ',', $contact['groups']); // remove "* " added by GMail $contact['groups'] = str_replace('* ', '', $contact['groups']); // replace strange delimiter added by GMail $contact['groups'] = str_replace(' ::: ', ',', $contact['groups']); } // Empty dates, e.g. "0/0/00", "0000-00-00 00:00:00" foreach (['birthday', 'anniversary'] as $key) { if (!empty($contact[$key])) { $date = preg_replace('/[0[:^word:]]/', '', $contact[$key]); if (empty($date)) { unset($contact[$key]); } } } if (!empty($contact['gender']) && ($gender = strtolower($contact['gender']))) { if (!in_array($gender, ['male', 'female'])) { unset($contact['gender']); } } // Convert address(es) to rcube_vcard data foreach ($contact as $idx => $value) { $name = explode(':', $idx); if (in_array($name[0], ['street', 'locality', 'region', 'zipcode', 'country'])) { $contact['address:' . $name[1]][$name[0]] = $value; unset($contact[$idx]); } } // Create vcard object $vcard = new rcube_vcard(); foreach ($contact as $name => $value) { $name = explode(':', $name); if (is_array($value) && $name[0] != 'address') { foreach ((array) $value as $val) { $vcard->set($name[0], $val, $name[1] ?? null); } } else { $vcard->set($name[0], $value, $name[1] ?? null); } } // add to the list $this->vcards[] = $vcard; } }