Files
roundcubemail/program/lib/Roundcube/rcube_csv2vcard.php
Philip Weir db2e201788 Contact import improvements (#9431)
* contact import: correct mismapped fields
* contacts: remove im:other field from UI, it does not exist in the vCard
* vcard: add some more maps for common vcard types to roundcube types
* contact import: list all possible roundcube contact fields in csv import UI, remove hard coded $local_map
* add SORT_LOCALE_STRING flag
* fix typos
* remove unwanted label
* move field list to csv2vcard
* move rcube_csv2vcard::list_fields to rcmail_action_contacts_import::list_fields as it relies on rcmail_action_contacts
* use single field map for csv2vcard imports, remove hardcoded version
* fix test
* small cs fix
* reformat csv2vcard.inc
* fix failing test
* restore existance check
* fix failing test again
2025-10-08 13:36:57 +02:00

373 lines
11 KiB
PHP

<?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 label to text mapping for English read from localization
*
* @var array
*/
protected $label_map = [];
/**
* 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:main',
'pager' => 'phone:pager',
'mobile' => 'phone:mobile',
'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 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')
{
$this->label_map = self::read_localization_file(RCUBE_LOCALIZATION_DIR . 'en_US/csv2vcard.inc');
// Localize fields map
if ($lang != 'en_US' && is_dir(RCUBE_LOCALIZATION_DIR . $lang)) {
$map = self::read_localization_file(RCUBE_LOCALIZATION_DIR . $lang . '/csv2vcard.inc');
if (!empty($map)) {
$this->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, $available)
{
// sanitize input
$elements = array_filter($elements, static function ($val) use ($available) {
return in_array($val, $available);
});
$this->map = $elements;
}
/**
* 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]);
if (count($lines) == 2) {
// first line of contents needed to properly identify fields in gmail CSV
$contents = $this->parse_line($lines[1]);
}
// check labels
for ($i = 0; $i < count($elements); $i++) {
if ($field = $this->label_map[$elements[$i]] ?? null) {
$this->map[$i] = $field;
}
}
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;
}
/**
* Load localization file
*
* @param string $file File location
*
* @return array Localization csv2vcard map
*/
protected static function read_localization_file($file)
{
$map = [];
if (is_file($file) && is_readable($file)) {
// use buffering to handle empty lines/spaces after closing PHP tag
ob_start();
require $file;
ob_end_clean();
}
return $map;
}
}