<?php
/*
* RCMCardDAV - CardDAV plugin for Roundcube webmail
*
* Copyright (C) 2011-2021 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>,
* Michael Stilkerich <ms@mike2k.de>
*
* This file is part of RCMCardDAV.
*
* RCMCardDAV is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* RCMCardDAV is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with RCMCardDAV. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace MStilkerich\CardDavAddressbook4Roundcube;
use Psr\Log\LoggerInterface;
use Sabre\VObject;
use Sabre\VObject\Component\VCard;
use MStilkerich\CardDavClient\AddressbookCollection;
use MStilkerich\CardDavAddressbook4Roundcube\Db\AbstractDatabase;
use carddav;
use rcube_utils;
/**
* @psalm-type SaveDataMultiField = list<string>
* @psalm-type SaveDataAddressField = array<string,string>
* @psalm-type SaveData = array{
* name?: string,
* cuid?: string,
* kind?: string,
* ID?: string,
* birthday?: string,
* nickname?: string,
* notes?: string,
* photo?: string | DelayedPhotoLoader,
* jobtitle?: string,
* showas?: string,
* anniversary?: string,
* assistant?: string,
* gender?: string,
* manager?: string,
* spouse?: string,
* maidenname?: string,
* organization?: string,
* department?: string
* } & array<string, SaveDataMultiField|SaveDataAddressField>
*
* @psalm-type SaveDataFromDC = array{
* name: string,
* kind: string,
* cuid?: string,
* ID?: string,
* birthday?: string,
* nickname?: string,
* notes?: string,
* photo?: string | DelayedPhotoLoader,
* jobtitle?: string,
* showas?: string,
* anniversary?: string,
* assistant?: string,
* gender?: string,
* manager?: string,
* spouse?: string,
* maidenname?: string,
* organization?: string,
* department?: string,
* vcard?: string,
* _carddav_vcard?: VCard,
* } & array<string, SaveDataMultiField|SaveDataAddressField>
*
* @psalm-type ColTypeDef = array{subtypes?: list<string>, subtypealias?: array<string,string>}
* @psalm-type ColTypeDefs = array<string,ColTypeDef>
*/
class DataConversion
{
/** @var string This is an empty VCard string to workaround an incompatibility of Roundcube's export code with the
* DelayedPhotoLoader object that we store in the photo attribute of save_data.
*/
private const EMPTY_VCF =
"BEGIN:VCARD\r\n" .
"VERSION:3.0\r\n" .
"FN:Dummy\r\n" .
"N:;;;;;\r\n" .
"END:VCARD\r\n";
/**
* @var array{simple: array<string,string>, multi: array<string,string>} VCF2RC
* maps VCard property names to roundcube keys
*/
private const VCF2RC = [
'simple' => [
'BDAY' => 'birthday',
'FN' => 'name',
'NICKNAME' => 'nickname',
'NOTE' => 'notes',
'PHOTO' => 'photo',
'TITLE' => 'jobtitle',
'UID' => 'cuid',
'X-ABShowAs' => 'showas',
'X-ANNIVERSARY' => 'anniversary',
'X-ASSISTANT' => 'assistant',
'X-GENDER' => 'gender',
'X-MANAGER' => 'manager',
'X-SPOUSE' => 'spouse',
'X-MAIDENNAME' => 'maidenname',
// the two kind attributes should not occur both in the same vcard
//'KIND' => 'kind', // VCard v4
'X-ADDRESSBOOKSERVER-KIND' => 'kind', // Apple Addressbook extension
],
'multi' => [
'EMAIL' => 'email',
'TEL' => 'phone',
'URL' => 'website',
'ADR' => 'address',
'IMPP' => 'im',
'X-AIM' => 'im:AIM',
'X-GADUGADU' => 'im:GaduGadu',
'X-GOOGLE-TALK' => 'im:GoogleTalk',
'X-GROUPWISE' => 'im:Groupwise',
'X-ICQ' => 'im:ICQ',
'X-JABBER' => 'im:Jabber',
'X-MSN' => 'im:MSN',
'X-SKYPE' => 'im:Skype',
'X-TWITTER' => 'im:Twitter',
'X-YAHOO' => 'im:Yahoo',
],
];
/**
* @var string[] IM_URISCHEME Maps IMPP roundcube subtype to the URI scheme to use in IMPP property. If not
* explicitly listed, use the service name lowercase. For custom one use x-unknown.
* See also: https://en.wikipedia.org/wiki/List_of_URI_schemes
*/
private const IM_URISCHEME = [
'GaduGadu' => "gg", // eMClient: gadu, Wikipedia/KAddressbook: gg
'GoogleTalk' => "gtalk", // Apple: xmpp, eMClient: google, Wikipedia: gtalk, KAddressbook: googletalk
'ICQ' => "icq", // Apple: aim
'Jabber' => "xmpp",
'MSN' => "msnim", // Apple/Wikipedia: msnim, KAddressbook/eMClient: msn
'Yahoo' => "ymsgr",
'Zoom' => "zoomus"
];
/**
* @var ColTypeDefs $coltypes
* Descriptions on the different attributes of address objects for roundcube
*/
private $coltypes = [
'name' => [],
'firstname' => [],
'surname' => [],
'maidenname' => [],
'email' => [
'subtypes' => ['home','work','other','internet'],
],
'middlename' => [],
'prefix' => [],
'suffix' => [],
'nickname' => [],
'jobtitle' => [],
'organization' => [],
'department' => [],
'gender' => [],
'phone' => [
'subtypes' => [
'home','work','home2','work2','mobile','main','homefax','workfax','car','pager','video',
'assistant','other'
],
],
'address' => [
'subtypes' => ['home','work','other'],
],
'birthday' => [],
'anniversary' => [],
'website' => [
'subtypes' => ['homepage','work','blog','profile','other'],
],
'notes' => [],
'photo' => [],
'assistant' => [],
'manager' => [],
'spouse' => [],
'im' => [
'subtypes' => [
'AIM',
'GaduGadu',
'GoogleTalk',
'Groupwise',
'ICQ',
'IRC',
'Jabber',
'Kakaotalk',
'Kik',
'Line',
'Matrix',
'MSN',
'QQ',
'SIP',
'Skype',
'Telegram',
'Twitter',
'WeChat',
'Yahoo',
'Zoom',
'other'
],
'subtypealias' => [
'gadu' => 'gadugadu',
'gg' => 'gadugadu',
'google' => 'googletalk',
'xmpp' => 'jabber',
'ymsgr' => 'yahoo',
]
],
];
/** @var array<string,list<string>> $xlabels custom labels defined in the addressbook */
private $xlabels = [];
/** @var string $abookId Database ID of the Addressbook this converter is associated with */
private $abookId;
/**
* Constructs a data conversion instance.
*
* The instance is bound to an Addressbook because some properties of the conversion such as specific labels are
* specific for an addressbook.
*
* The data converter may need access to the database and the carddav server for specific operations such as storing
* the custom labels or downloading resources from the server that are referenced by an URI within a VCard. These
* dependencies are injected with the constructor to allow for testing of this class using stub versions.
*
* @param string $abookId The database ID of the addressbook the data conversion object is bound to.
*/
public function __construct(string $abookId)
{
$this->abookId = $abookId;
$this->addextrasubtypes();
}
/**
* @return ColTypeDefs
*/
public function getColtypes(): array
{
return $this->coltypes;
}
/**
* Allows to query if a property is a multi-value property (e.g., phone, email).
*
* @return bool True if the given property is a multi-value property, false if it is a single-value property.
*/
public function isMultivalueProperty(string $attrname): bool
{
if (isset($this->coltypes[$attrname])) {
return isset($this->coltypes[$attrname]['subtypes']);
} else {
throw new \Exception("$attrname is not a known roundcube contact property");
}
}
/**
* Creates the roundcube representation of a contact from a VCard.
*
* If the card contains a URI referencing an external photo, this
* function will download the photo and inline it into the VCard.
* The returned array contains a boolean that indicates that the
* VCard was modified and should be stored to avoid repeated
* redownloads of the photo in the future. The returned VCard
* object contains the modified representation and can be used
* for storage.
*
* @param VCard $vcard Sabre VCard object
*
* @return SaveDataFromDC Roundcube representation of the VCard
*/
public function toRoundcube(VCard $vcard, AddressbookCollection $davAbook): array
{
$save_data = [
// DEFAULTS
'kind' => 'individual',
// this causes roundcube's own vcard creation code be skipped in the VCard export
'vcard' => self::EMPTY_VCF,
];
foreach (self::VCF2RC['simple'] as $vkey => $rckey) {
/** @var ?VObject\Property */
$property = $vcard->{$vkey};
if (isset($property)) {
$property = (string) $property;
if (strlen($property) > 0) {
$save_data[$rckey] = $property;
}
}
}
// Set a proxy for photo computation / retrieval on demand
if (key_exists('photo', $save_data) && isset($vcard->PHOTO)) {
$save_data["photo"] = new DelayedPhotoLoader($vcard, $davAbook);
}
$property = $vcard->N;
if (isset($property)) {
$attrs = [ "surname", "firstname", "middlename", "prefix", "suffix" ];
/** @var list<string> */
$N = $property->getParts();
for ($i = 0; $i < min(count($N), count($attrs)); $i++) {
if (strlen($N[$i]) > 0) {
$save_data[$attrs[$i]] = $N[$i];
}
}
}
$property = $vcard->ORG;
if (isset($property)) {
/** @var list<string> */
$ORG = $property->getParts();
if (count($ORG) > 0) {
$organization = $ORG[0];
if (strlen($organization) > 0) {
$save_data['organization'] = $organization;
}
if (count($ORG) > 1) {
$department = implode("; ", array_slice($ORG, 1));
if (strlen($department) > 0) {
$save_data['department'] = $department;
}
}
}
}
foreach (self::VCF2RC['multi'] as $vkey => $rckey) {
/** @var ?VObject\Property */
$properties = $vcard->{$vkey};
if (isset($properties)) {
// if the attribute already maps to a specific subtype, it is contained in rckey
[$rckey] = $rckeyComp = explode(':', $rckey, 2);
/** @var VObject\Property $prop */
foreach ($properties as $prop) {
$label = (count($rckeyComp) < 2) ? $this->getAttrLabel($vcard, $prop, $rckey) : $rckeyComp[1];
if (method_exists($this, "toRoundcube$vkey")) {
/** @var null|string|SaveDataAddressField special handler for structured property */
$propValue = call_user_func([$this, "toRoundcube$vkey"], $prop);
if (!isset($propValue)) {
continue;
}
} else {
$propValue = (string) $prop;
if (strlen($propValue) == 0) {
continue;
}
}
/** @var list<string> */
$existingValues = $save_data["$rckey:$label"] ?? [];
if (!in_array($propValue, $existingValues)) {
$save_data["$rckey:$label"][] = $propValue;
}
}
}
}
// set displayname if not set from VCard
if (!isset($save_data["name"]) || strlen((string) $save_data["name"]) == 0) {
$save_data["name"] = self::composeDisplayname($save_data);
}
$save_data['_carddav_vcard'] = $vcard;
return $save_data;
}
/**
* Creates roundcube address data from an ADR VCard property.
*
* @param VObject\Property The ADR property to use as input.
* @return ?SaveDataAddressField The roundcube address data created from the property.
*/
private function toRoundcubeADR(VObject\Property $prop): ?array
{
$attrs = [
'pobox', // post office box
'extended', // extended address
'street', // street address
'locality', // locality (e.g., city)
'region', // region (e.g., state or province)
'zipcode', // postal code
'country' // country name
];
/** @var list<string> */
$p = $prop->getParts();
$addr = [];
for ($i = 0; $i < min(count($p), count($attrs)); $i++) {
if (strlen($p[$i]) > 0) {
$addr[$attrs[$i]] = $p[$i];
}
}
return empty($addr) ? null : $addr;
}
/**
* Creates roundcube instant messaging data
*
* @param VObject\Property The IMPP property to use as input.
* @return ?string The roundcube data created from the property.
*/
private function toRoundcubeIMPP(VObject\Property $prop): ?string
{
// Examples:
// From iCloud Web Addressbook: IMPP;X-SERVICE-TYPE=aim;TYPE=HOME;TYPE=pref:aim:jdoe@example.com
// From Nextcloud: IMPP;TYPE=SKYPE:jdoe@example.com
// Note: the nextcloud example does not have an URI value, thus it's not compliant with RFC 4770
$comp = explode(":", (string) $prop, 2);
$ret = $comp[count($comp) == 2 ? 1 : 0];
if (strlen($ret) == 0) {
return null;
}
return $ret;
}
/**
* Creates a new or updates an existing vcard from save data.
*
* @param SaveData $save_data The roundcube representation of the contact / group
* @param ?VCard $vcard The original VCard from that the address data was originally passed to roundcube. If a new
* VCard should be created, this parameter must be null.
* @return VCard Returns the created / updated VCard. If a VCard was passed in the $vcard parameter, it is updated
* in place.
*/
public function fromRoundcube(array $save_data, ?VCard $vcard = null): VCard
{
$isGroup = (($save_data['kind'] ?? "") === "group");
if (!isset($save_data["name"]) || strlen($save_data["name"]) == 0) {
if (!$isGroup) {
$save_data["showas"] = $this->determineShowAs($save_data);
}
$save_data["name"] = $this->composeDisplayname($save_data);
}
if (!isset($vcard)) {
// create fresh minimal vcard
$vcard = new VObject\Component\VCard(['VERSION' => '3.0']);
}
// update revision
$vcard->REV = $this->dateTimeString();
// N is mandatory
if ($isGroup) {
$vcard->N = [$save_data['name'],"","","",""];
} else {
$nAttr = array_fill(0, 5, "");
foreach (['surname', 'firstname', 'middlename', 'prefix', 'suffix'] as $idx => $nameKey) {
if (isset($save_data[$nameKey])) {
$nAttr[$idx] = $save_data[$nameKey];
}
}
$vcard->N = $nAttr;
}
$this->setOrgProperty($save_data, $vcard);
$this->setSingleValueProperties($save_data, $vcard);
$this->setMultiValueProperties($save_data, $vcard);
return $vcard;
}
/**
* Exports a VCard
*
* We provide the VCard as is on the server, with the following exceptions:
* - PHOTO referenced by URI is downloaded and included in the VCard
* - PHOTO with X-ABCROP-RECTANGLE parameter is stored cropped
*
* @param VCard $vcard The original VCard object from that the save_data was created
* @param SaveDataFromDC $save_data The save data created from the VCard
* @return string The exported and serialized VCard
*/
public static function exportVCard(VCard $vcard, array $save_data): string
{
$photoData = (string) ($save_data["photo"] ?? "");
if (strlen($photoData) > 0) {
self::setPhotoProperty($vcard, $photoData);
}
// Note: if DelayedPhotoLoader fails for whatever reason, we keep the original PHOTO property untouched
return $vcard->serialize();
}
/**
* Returns an RFC2425 date-time string for the current time in UTC.
*
* Example: 2020-11-12T16:18:41Z
*
* T is used as a delimiter to separate date and time.
* Z is the zone designator for the zero UTC offset.
* See also ISO 8601.
*/
private function dateTimeString(): string
{
return gmdate("Y-m-d\TH:i:s\Z");
}
/**
* Sets the ORG property in a VCard from roundcube contact data.
*
* The ORG property is populated from the organization and department attributes of roundcube's data.
* The department is split into several components separated by semicolon and stored as different parts of the ORG
* property.
*
* If neither organization nor department are given (or empty), the ORG property is deleted from the VCard.
*
* @param SaveData $save_data The roundcube representation of the contact
* @param VCard $vcard The VCard to set the ORG property for.
*/
private function setOrgProperty(array $save_data, VCard $vcard): void
{
$orgParts = [];
if (isset($save_data['organization']) && strlen($save_data['organization']) > 0) {
$orgParts[] = $save_data['organization'];
}
if (isset($save_data['department']) && strlen($save_data['department']) > 0) {
// the first element of ORG corresponds to organization, if that field is not filled but organization is
// we need to store an empty value explicitly (otherwise, department would become organization when reading
// back the VCard).
if (empty($orgParts)) {
$orgParts[] = "";
}
$orgParts = array_merge($orgParts, preg_split('/\s*;\s*/', $save_data['department']));
}
if (empty($orgParts)) {
unset($vcard->ORG);
} else {
$vcard->ORG = $orgParts;
}
}
/**
* Sets the PHOTO property in a VCard to an inlined photo, including the necessary parameters.
*/
private static function setPhotoProperty(VCard $vcard, string $photoData): void
{
$vcard->PHOTO = $photoData;
if (isset($vcard->PHOTO)) {
$vcard->PHOTO['ENCODING'] = 'b';
$vcard->PHOTO['VALUE'] = 'binary';
}
}
/**
* Sets properties with a single value in a VCard from roundcube contact data.
*
* About the contents of save_data:
* - Empty / deleted fields in roundcube either are missing from save_data or contain an empty string as value.
* It is not really clear under what circumstances a field is present empty and when it's missing entirely.
* - Fields that are not shown to the user (most importantly: UID) will never be provided in roundcube save_data
* -> Those are retained from the original vcard (we can check coltypes for the attributes roundcube knows of)
* - Special case photo: It is only set if it was edited. If it is deleted, it is set to an empty string. If it
* was not changed, no photo key is present in save_data.
*
* @param SaveData $save_data The roundcube representation of the contact
* @param VCard $vcard The VCard to set the ORG property for.
*/
private function setSingleValueProperties(array $save_data, VCard $vcard): void
{
$logger = Config::inst()->logger();
foreach (self::VCF2RC['simple'] as $vkey => $rckey) {
if (isset($save_data[$rckey])) {
$rcValue = $save_data[$rckey];
if (!is_string($rcValue)) {
$logger->error("save data $rckey must be string" . print_r($rcValue, true));
continue;
}
if (strlen($rcValue) == 0) {
unset($vcard->{$vkey});
} else {
$vcard->{$vkey} = $rcValue;
// Special handling for PHOTO
// If PHOTO is set from roundcube data, set the parameters properly
if ($rckey === "photo") {
self::setPhotoProperty($vcard, $rcValue);
}
}
} else {
// If existing, preserve properties not known to roundcube (e.g. UID)
// If photo is not set, it was not changed in roundcube -> preserve too if exists
if (isset($this->coltypes[$rckey]) && $rckey != "photo") {
unset($vcard->{$vkey});
}
}
}
}
/**
* Sets properties with possibly multiple values in a VCard from roundcube contact data.
*
* The current approach is to completely erase existing properties from the VCard and to create from roundcube data
* from scratch. The implication of this is that only subtype (the one selected in roundcube) can be preserved, if a
* property had multiple subtypes, the other ones will be lost.
*
* About the contents of save_data:
* - Multi-value fields (email, address, phone, website) have a key that includes the subtype setting delimited by
* a colon (e.g. "email:home"). The value of each setting is an array. These arrays may include empty members if
* the field was part of the edit mask but not filled.
*
* @param SaveData $save_data The roundcube representation of the contact
* @param VCard $vcard The VCard to set the ORG property for.
*/
private function setMultiValueProperties(array $save_data, VCard $vcard): void
{
// delete and fully recreate all entries; there is no easy way of mapping an address in the existing card to an
// address in the save data, as subtypes may have changed
foreach (array_keys(self::VCF2RC['multi']) as $vkey) {
unset($vcard->{$vkey});
}
// now clear out all orphan X-ABLabel properties
$this->clearOrphanAttrLabels($vcard);
// and finally recreate the attributes
foreach (self::VCF2RC['multi'] as $vkey => $rckey) {
// Determine the actually present subtypes in the save data; if the VCard property is mapped to a specific
// subtype, restrict the selection to that subtype.
[$rckey] = $rckeyComp = explode(':', $rckey, 2);
if (count($rckeyComp) > 1) {
[ $rckey, $rclabel ] = $rckeyComp;
$subtypes = isset($save_data["$rckey:$rclabel"]) ? [ $rclabel ] : [];
} else {
$rclabel = null;
$subtypes = preg_filter("/^$rckey:?/", '', array_keys($save_data), 1);
}
foreach ($subtypes as $subtype) {
// In some cases, roundcube passes a multi-value attribute without subtype (e.g. "email"), e.g. "add
// contact to addressbook" from mail view
$sdkey = strlen($subtype) > 0 ? "$rckey:$subtype" : $rckey;
// Cast to array - roundcube passes simple string in some cases, e.g. "add contact to addressbook" from
// mail view
/** @var SaveDataMultiField $values */
$values = (array) $save_data[$sdkey];
foreach ($values as $value) {
$prop = null;
$mkey = str_replace("-", "_", strtoupper($vkey));
if (method_exists($this, "fromRoundcube$mkey")) {
/** @var ?VObject\Property special handler for structured property */
$prop = call_user_func([$this, "fromRoundcube$mkey"], $value, $vcard, $subtype);
} else {
if (strlen($value) > 0) {
$prop = $vcard->createProperty($vkey, $value);
$vcard->add($prop);
}
}
// in case $rclabel is set, the property is implicitly assigned a subtype (e.g. X-SKYPE)
if (isset($prop) && !isset($rclabel)) {
$this->setAttrLabel($vcard, $prop, $rckey, $subtype);
}
}
}
}
}
/**
* Creates an ADR property from roundcube address data and adds it to a VCard.
*
* This function is passed an address array as provided by roundcube and from it creates a property if at least one
* of the address fields is set to a non empty value. Otherwise, null is returned.
*
* @param SaveDataAddressField $address The address array as provided by roundcube
* @param VCard $vcard The VCard to add the property to.
* @param string $_subtype The subtype/label assigned by roundcube
* @return ?VObject\Property The created property, null if no property was created.
*/
private function fromRoundcubeADR(array $address, VCard $vcard, string $_subtype): ?VObject\Property
{
$prop = null;
$adrValue = [ "" /* PO box */, "" /* extended address */ ];
$haveNonEmptyField = false;
foreach (['street', 'locality', 'region', 'zipcode', 'country'] as $adrAttr) {
$val = $address[$adrAttr] ?? "";
$haveNonEmptyField = $haveNonEmptyField || (strlen($val) > 0);
$adrValue[] = $val;
}
if ($haveNonEmptyField) {
$prop = $vcard->createProperty('ADR', $adrValue);
$vcard->add($prop);
}
return $prop;
}
/**
* Creates an URL property from roundcube address data and adds it to a VCard.
*
* The extra behavior of this function is to add a VALUE=URI parameter to the created VCard.
*
* @param string $url The URL value
* @param VCard $vcard The VCard to add the property to.
* @param string $_subtype The subtype/label assigned by roundcube
* @return ?VObject\Property The created property, null if no property was created.
*/
private function fromRoundcubeURL(string $url, VCard $vcard, string $_subtype): ?VObject\Property
{
$prop = null;
if (strlen($url) > 0) {
$prop = $vcard->createProperty('URL', $url, [ "VALUE" => "URI" ]);
$vcard->add($prop);
}
return $prop;
}
/**
* Creates an IMPP property from roundcube address data and adds it to a VCard.
*
* This function is passed a messenger handle.
*
* @param string $address The address array as provided by roundcube
* @param VCard $vcard The VCard to add the property to.
* @param string $subtype The subtype/label assigned by roundcube
* @return ?VObject\Property The created property, null if no property was created.
*/
private function fromRoundcubeIMPP(string $address, VCard $vcard, string $subtype): ?VObject\Property
{
$prop = null;
if (strlen($address) > 0) {
$scheme = $this->determineUriSchemeForIM($subtype);
$prop = $vcard->createProperty(
'IMPP',
"$scheme:$address",
[
'TYPE' => $subtype,
'X-SERVICE-TYPE' => $subtype,
]
);
$vcard->add($prop);
}
return $prop;
}
private function determineUriSchemeForIM(string $subtype): string
{
$scheme = 'x-unknown';
if (isset(self::IM_URISCHEME[$subtype])) {
$scheme = self::IM_URISCHEME[$subtype];
} elseif (preg_match('/^[A-Za-z][A-Za-z0-9+.-]*$/', $subtype)) {
$scheme = strtolower($subtype);
}
return $scheme;
}
/******************************************************************************************************************
************ + + + ************
************ X-ABLabel Extension ************
************ + + + ************
*****************************************************************************************************************/
/**
* Returns all the property groups used in a VCard.
*
* For example, [ "ITEM1", "ITEM2" ] would be returned if the vcard contained the following:
* ITEM1.X-ABLABEL: FOO
* ITEM2.X-ABLABEL: BAR
*
* @return string[] The list of used groups, in upper case.
*/
private function getAllPropertyGroups(VCard $vcard): array
{
$groups = [];
/** @var VObject\Property $p */
foreach ($vcard->children() as $p) {
if (!empty($p->group)) {
$groups[strtoupper($p->group)] = true;
}
}
return array_keys($groups);
}
/**
* This function clears all orphan X-ABLabel properties from a VCard.
*
* An X-ABLabel is considered orphan if its property group is not used by any other properties.
*
* The special case that X-ABLabel property exists that is not part of any group is not considered an orphan, and it
* should not occur because X-ABLabel only makes sense when assigned to another property via the shared group.
*/
private function clearOrphanAttrLabels(VCard $vcard): void
{
// groups used by Properties OTHER than X-ABLabel
$usedGroups = [];
$labelProps = [];
/** @var VObject\Property $p */
foreach ($vcard->children() as $p) {
if (!empty($p->group)) {
if (strcasecmp($p->name, "X-ABLabel") === 0) {
$labelProps[] = $p;
} else {
$usedGroups[strtoupper($p->group)] = true;
}
}
}
foreach ($labelProps as $p) {
if (!isset($usedGroups[strtoupper($p->group)])) {
$vcard->remove($p);
}
}
}
/**
* This function assigns a label (subtype) to a VCard multi-value property.
*
* Typical multi-value properties are EMAIL, TEL and ADR.
*
* Note that roundcube/rcmcarddav only supports a single subtype per property, whereas VCard allows to have more
* than one. As an effect, when a card is updated only the subtype selected in roundcube will be preserved, possible
* extra subtypes will be lost.
*
* If the given label is empty, not TYPE parameter is assigned.
*
* If the given label is one of the known standard labels, it will be assigned as a TYPE parameter of the property,
* otherwise it will be assigned using the X-ABLabel extension.
*
* Note: vcard groups are case-insensitive per RFC6350.
*
* @param VCard $vcard The VCard that the property belongs to
* @param VObject\Property $vprop The property to set the subtype for. A pristine property is assumed that has no
* TYPE parameter set and belong to no property group.
* @param string $attrname The key used by roundcube for the attribute (e.g. address, email)
* @param string $newlabel The label to assign to the given property.
*/
private function setAttrLabel(VCard $vcard, VObject\Property $vprop, string $attrname, string $newlabel): void
{
// Don't set a type parameter if there is no label
if (strlen($newlabel) == 0) {
return;
}
// X-ABLabel?
if (in_array($newlabel, $this->xlabels[$attrname])) {
$usedGroups = $this->getAllPropertyGroups($vcard);
$item = 0;
do {
++$item;
$group = "ITEM$item";
} while (in_array(strtoupper($group), $usedGroups));
$vprop->group = $group;
$labelProp = $vcard->createProperty("$group.X-ABLabel", $newlabel);
$vcard->add($labelProp);
} else {
// Standard Label
$vprop['TYPE'] = $newlabel;
}
}
/**
* Provides the label (subtype) of a multi-value property.
*
* VCard allows a property to have several TYPE parameters. In addition, it is possible to specify user-defined
* types using the X-ABLabel extension. However, in roundcube we can only show one label / subtype, so we need a way
* to select which of the available labels to show.
*
* The following algorithm is used to select the label (first match is used):
* 1. If there is a custom handler to extract a label for a property, it is called to provide the label.
* 2. If the property is part of a group that also contains an X-ABLabel property, the X-ABLabel value is used.
* 3. The TYPE parameter that, of all the specified TYPE parameters, is listed first in the
* coltypes[<attr>]["subtypes"] array. Note that TYPE parameter values not listed in the subtypes array will be
* ignored in the selection.
* 4. If no known TYPE parameter value is specified, "other" is used, which is a valid subtype for all currently
* supported multi-value properties.
*/
private function getAttrLabel(VCard $vcard, VObject\Property $vprop, string $attrname): string
{
// 1. Check if there is a property-specific handler to provide label
$vkey = str_replace("-", "_", strtoupper($vprop->name));
if (method_exists($this, "getAttrLabel$vkey")) {
/** @var string */
return call_user_func([$this, "getAttrLabel$vkey"], $vcard, $vprop);
}
// 2. check for a custom label using Apple's X-ABLabel extension
/** @psalm-var ?string $group */
$group = $vprop->group;
if (isset($group)) {
/** @var ?VObject\Property */
$xlabel = $vcard->{"$group.X-ABLabel"};
if (!empty($xlabel)) {
// special labels from Apple namespace are stored in the form "_$!<Label>!$_" - extract label
$xlabel = preg_replace(';_\$!<(.*)>!\$_;', '$1', (string) $xlabel);
// add to known types if new
if (!in_array($xlabel, $this->coltypes[$attrname]['subtypes'] ?? [])) {
$this->storeextrasubtype($attrname, $xlabel);
}
return $xlabel;
}
}
// 3. select a known standard label if available
$selection = null;
if (isset($vprop["TYPE"]) && !empty($this->coltypes[$attrname]['subtypes'])) {
/** @var VObject\Parameter */
foreach ($vprop["TYPE"] as $type) {
$type = strtolower((string) $type);
$pref = array_search($type, $this->coltypes[$attrname]['subtypes'], true);
if ($pref !== false) {
if (!isset($selection) || $pref < $selection[1]) {
$selection = [ $type, $pref ];
}
}
}
}
// 4. return default subtype
return $selection[0] ?? 'other';
}
/**
* Acquires label candidates for the IMPP property.
*
* Candidates are taken from the URI component and X-SERVICE-TYPE parameters of the property.
*
* @return string The determined label.
*/
private function getAttrLabelIMPP(VCard $_vcard, VObject\Property $prop): string
{
assert(!empty($this->coltypes["im"]["subtypes"]), "im attribute requires a list of subtypes");
$subtypesLower = array_map('strtolower', $this->coltypes["im"]["subtypes"]);
// check X-SERVICE-TYPE parameter (seen in entries created by Apple Addressbook)
if (isset($prop["X-SERVICE-TYPE"])) {
/** @var VObject\Parameter */
foreach ($prop["X-SERVICE-TYPE"] as $type) {
$type = (string) $type;
$ltype = strtolower($type);
$pos = array_search($ltype, $subtypesLower, true);
if ($pos === false) {
// custom type
$this->storeextrasubtype('im', $type);
return $type;
} else {
return $this->coltypes["im"]['subtypes'][$pos];
}
}
}
// check URI scheme
$comp = explode(":", strtolower((string) $prop), 2);
if (count($comp) == 2) {
$ltype = $comp[0];
$ltype = $this->coltypes["im"]["subtypealias"][$ltype] ?? $ltype;
$pos = array_search($ltype, $subtypesLower, true);
if ($pos !== false) {
return $this->coltypes["im"]['subtypes'][$pos];
}
}
// check TYPE parameter
if (isset($prop["TYPE"])) {
/** @var VObject\Parameter */
foreach ($prop["TYPE"] as $type) {
$ltype = strtolower((string) $type);
$ltype = $this->coltypes["im"]["subtypealias"][$ltype] ?? $ltype;
$pos = array_search($ltype, $subtypesLower, true);
if ($pos !== false) {
return $this->coltypes["im"]['subtypes'][$pos];
}
}
}
return 'other';
}
/**
* Stores a custom label in the database (X-ABLabel extension).
*
* @param string Name of the type/category (phone,address,email)
* @param string Name of the custom label to store for the type
*/
private function storeextrasubtype(string $typename, string $subtype): void
{
$db = Config::inst()->db();
$db->insert("xsubtypes", ["typename", "subtype", "abook_id"], [[$typename, $subtype, $this->abookId]]);
$this->coltypes[$typename]['subtypes'][] = $subtype;
$this->xlabels[$typename][] = $subtype;
}
/**
* Adds known custom labels to the roundcube subtype list (X-ABLabel extension).
*
* Reads the previously seen custom labels from the database and adds them to the
* roundcube subtype list in #coltypes and additionally stores them in the #xlabels
* list.
*/
private function addextrasubtypes(): void
{
$db = Config::inst()->db();
$this->xlabels = [];
foreach ($this->coltypes as $attr => $v) {
if (key_exists('subtypes', $v)) {
$this->xlabels[$attr] = [];
}
}
/** @var list<array{typename: string, subtype: string}> read extra subtypes */
$xtypes = $db->get(['abook_id' => $this->abookId], ['typename', 'subtype'], 'xsubtypes');
foreach ($xtypes as $row) {
[ "typename" => $attr, "subtype" => $subtype ] = $row;
$this->coltypes[$attr]['subtypes'][] = $subtype;
$this->xlabels[$attr][] = $subtype;
}
}
/******************************************************************************************************************
************ + + + ************
************ X-ABShowAs Extension ************
************ + + + ************
*****************************************************************************************************************/
/**
* Determines the showas setting (individual vs. company) by heuristic from the entered data.
*
* The showas setting allows addressbooks to display a contact as an organization rather than an individual.
*
* If no setting of showas is available (e.g. new contact created in roundcube):
* - the setting will be set to COMPANY if ONLY organization is given (but no firstname / surname)
* - otherwise it will be set to display as INDIVIDUAL
*
* If an existing ShowAs=COMPANY setting is given, but the organization field is empty, the setting will be reset to
* INDIVIDUAL.
*
* @param SaveData $save_data The address data as roundcube's internal format, as entered by
* the user. For update of an existing contact, the showas key must
* be populated with the previous value.
* @return string INDIVIDUAL or COMPANY
*/
private function determineShowAs(array $save_data): string
{
$showAs = $save_data['showas'] ?? "";
if (empty($showAs)) { // new contact
if (empty($save_data['surname']) && empty($save_data['firstname']) && !empty($save_data['organization'])) {
$showAs = 'COMPANY';
} else {
$showAs = 'INDIVIDUAL';
}
} else { // update of contact
// organization not set but showas==COMPANY => show as INDIVIDUAL
if (empty($save_data['organization'])) {
$showAs = 'INDIVIDUAL';
}
}
return $showAs;
}
/**
* Determines the name to be displayed for a contact. The routine
* distinguishes contact cards for individuals from organizations.
*
* From roundcube: Roundcube sets the name attribute either to an explicitly set "Display Name" field by the user,
* or computes a name from first name and last name attributes. If roundcube cannot compose a name from the entered
* data, the display name is empty. We set the displayname in this case only, because whenever a name attribute is
* provided by roundcube, it is possible that it was an explicitly entered value by the user which we must not
* overturn.
*
* From a VCard, the FN is mandatory. However, we may be served non-compliant VCards, or VCards with an empty FN
* value. In those cases, we will set the display name, otherwise we will take the value provided in the VCard.
*
* @param SaveData $save_data The address data as roundcube's internal format.
* @return string The composed displayname
*/
private static function composeDisplayname(array $save_data): string
{
$showAs = $save_data['showas'] ?? "";
if (strcasecmp($showAs, 'COMPANY') == 0 && !empty($save_data['organization'])) {
return $save_data['organization'];
}
// try from name
$dname = [];
foreach (["firstname", "surname"] as $attr) {
if (!empty($save_data[$attr])) {
$dname[] = $save_data[$attr];
}
}
if (!empty($dname)) {
return implode(' ', $dname);
}
// no name? try email and phone
$epKeys = preg_grep(";^(email|phone):;", array_keys($save_data));
sort($epKeys, SORT_STRING);
foreach ($epKeys as $epKey) {
/** @var SaveDataMultiField */
$epVals = $save_data[$epKey];
foreach ($epVals as $epVal) {
if (!empty($epVal)) {
return $epVal;
}
}
}
// still no name? set to unknown and hope the user will fix it
return 'Unset Displayname';
}
}
// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120
|