RoundCube Webmail
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

695 lines
23 KiB

<?php
/*
+-----------------------------------------------------------------------+
| This file is part of the Roundcube Webmail client |
| |
| Copyright (C) The Roundcube Dev Team |
| |
| Licensed under the GNU General Public License version 3 or |
| any later version with exceptions for skins & plugins. |
| See the README file for a full license statement. |
| |
| PURPOSE: |
| CSV to vCard data conversion |
+-----------------------------------------------------------------------+
| Author: Aleksander Machniak <alec@alec.pl> |
+-----------------------------------------------------------------------+
*/
/**
* 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;
}
}