, * Michael Stilkerich * * 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 . */ declare(strict_types=1); namespace MStilkerich\RCMCardDAV; use Exception; use InvalidArgumentException; use Sabre\VObject; use Sabre\VObject\Component\VCard; use rcube_addressbook; use rcube_result_set; use MStilkerich\CardDavClient\{Account, AddressbookCollection}; use MStilkerich\CardDavClient\Services\Sync; use MStilkerich\RCMCardDAV\Db\{AbstractDatabase,DbAndCondition,DbOrCondition}; use MStilkerich\RCMCardDAV\Frontend\AddressbookManager; /** * @psalm-import-type SaveData from DataConversion * @psalm-import-type SaveDataFromDC from DataConversion * @psalm-import-type SaveDataAddressField from DataConversion * * @psalm-import-type AbookCfg from AddressbookManager * * @psalm-type GroupSaveData = array{ * ID: string, * id: string, * name: string * } */ class Addressbook extends rcube_addressbook { /** @var string Separator character used by roundcube to encode multiple values in a single string. */ private const SEPARATOR = ','; /** @var ?AddressbookCollection The DAV AddressbookCollection object */ private $davAbook = null; /** @var DataConversion Converter between VCard and roundcube's representation of contacts. */ private $dataConverter; /** @var string Database ID of the addressbook */ private $id; /** @var list An additional filter to limit contact searches */ private $filter = []; /** @var ?rcube_result_set The result of the last get_record(), list_records() or search() operation */ private $result = null; /** @var Account Account for CardDAV access */ private $account; /** @var AbookCfg Various options for the behavior of the addressbook */ private $config; /** @var array $table_cols * attributes that are redundantly stored in the contact table and need * not be parsed from the vcard */ private $table_cols = ['id', 'name', 'email', 'firstname', 'surname', 'organization']; /** * Constructs an addressbook object. * * @param string $dbid The database ID of the addressbook * @param Account $account Account object for CardDAV access * @param AbookCfg $config Options for the addressbook */ public function __construct(string $dbid, Account $account, array $config) { $this->account = $account; $this->config = $config; $this->primary_key = 'id'; $this->groups = true; $this->readonly = ($config['readonly'] != '0'); $this->date_cols = ['birthday', 'anniversary']; $this->id = $dbid; $this->dataConverter = new DataConversion($dbid); $this->coltypes = $this->dataConverter->getColtypes(); $this->ready = true; } /************************************************************************ * rcube_addressbook API ***********************************************************************/ /** * Returns addressbook name (e.g. for addressbooks listing). * * @return string name of this addressbook */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function get_name(): string { return $this->config['name']; } /** * Sets a search filter. * * This affects the contact set considered when using the count() and list_records() operations to those * contacts that match the filter conditions. If no search filter is set, all contacts in the addressbook are * considered. * * This filter mechanism is applied in addition to other filter mechanisms, see the description of the count() * operation. * * @param mixed $filter Search params to use in listing method, obtained by get_search_set() */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function set_search_set($filter): void { if (is_array($filter)) { $ftyped = []; foreach (array_keys($filter) as $k) { if ($filter[$k] instanceof DbAndCondition) { $ftyped[] = $filter[$k]; } else { throw new InvalidArgumentException(__METHOD__ . " requires a DbAndCondition[] type filter"); } } $this->filter = $ftyped; } else { throw new InvalidArgumentException(__METHOD__ . " requires a DbAndCondition[] type filter"); } } /** * Returns the current search filter. * * @return list Search properties used by this class */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function get_search_set() { return $this->filter; } /** * Reset saved results and search parameters */ public function reset(): void { $this->result = null; $this->filter = []; } /** * Lists the current set of contact records. * * See the description of count() for the criteria determining which contacts are considered for the listing. * * The actual records returned may be fewer, as only the records for the current page are returned. The returned * records may be further limited by the $subset parameter, which means that only the first or last $subset records * of the page are returned, depending on whether $subset is positive or negative. If $subset is 0, all records of * the page are returned. The returned records are found in the $records property of the returned result set. * * Finally, the $first property of the returned result set contains the index into the total set of filtered records * (i.e. not considering the segmentation into pages) of the first returned record before applying the $subset * parameter (i.e., $first is always a multiple of the page size). * * The $nocount parameter is an optimization that allows to skip querying the total amount of records of the * filtered set if the caller is only interested in the records. In this case, the $count property of the returned * result set will simply contain the number of returned records, but the filtered set may contain more records than * this. * * The result of the operation is internally cached for later retrieval using get_result(). * * @param ?array $cols List of columns to include in the returned records (null means all) * @param int $subset Only return this number of records of the current page, use negative values for tail * @param bool $nocount True to skip the count query (select only) * * @return rcube_result_set Indexed list of contact records, each a hash array */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function list_records($cols = null, $subset = 0, $nocount = false) { $first = ($this->list_page - 1) * $this->page_size; $result = new rcube_result_set(0, $first); /** @var ?list $cols */ try { $numRecords = $this->listRecordsReadDB($cols, $subset, $result); if ($nocount) { $result->count = $numRecords; } elseif ($this->list_page <= 1 && $numRecords < $this->page_size && $subset == 0) { // If we are on the first page, no subset was requested and the number of records is smaller than the // page size, there are no more records, so we can skip the COUNT query $result->count = $numRecords; } else { $result->count = $this->doCount(); } } catch (Exception $e) { $logger = Config::inst()->logger(); $logger->error(__METHOD__ . " exception: " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SEARCH, $e->getMessage()); } $this->result = $result; return $result; } /** * Search contacts * * Depending on the given parameters the search() function operates in different modes (in the order listed): * * Mode "Direct ID search" - $fields is either 'ID' or $this->primary_key: * $values is either: a string of contact IDs separated by self::SEPARATOR (,) * an array of contact IDs * - Any contact with one of the given IDs is returned * - $mode is ignored in this case * * Mode "Advanced search" - $value is an array * - Each value in $values is the search value for the field in $fields at the same index * - All fields must match their value to be included in the result ("AND" semantics) * * Mode "Search all fields" - $fields is '*' (note: $value is a single string) * - Any field must match the value to be included in the result ("OR" semantics) * * Mode "Search given fields" * - Any of the given fields must match the value to be included in the result ("OR" semantics) * * All matching is done case-insensitive. * * The search settings are remembered (see set_search_set()) until reset using the reset() function. They can be * retrieved using get_search_set(). The remembered search settings must be considered by list_records() and * count(). * * The search mode can be set by the admin via the config.inc.php setting addressbook_search_mode, which defaults to * 0. It is used as a bit mask, but the search modes are mostly exclusive; from the roundcube code, I take the * following interpretation: * bits [1..0] = 0b00: Search all (*abc*) * bits [1..0] = 0b01: Search strict (case insensitive =) * bits [1..0] = 0b10: Prefix search (abc*) * The purpose of SEARCH_GROUPS is not clear to me and not considered. * * When records are requested in the returned rcube_result_set ($select is true), the results will only include the * contacts of the current page (see list_page, page_size). The behavior is as described with the list_records * function, and search() can be thought of as a sequence of set_search_set() and list_records() under that filter. * * If $nocount is true, the count property of the returned rcube_result_set will contain the amount of records * contained within that set. Calling search() with $select=false and $nocount=true is not a meaningful use case and * will result in an empty result set without records and a count property of 0, which gives no indication on the * actual record set matching the given filter. * * The result of the operation is internally cached for later retrieval using get_result(). * * @param string|string[] $fields Field names to search in * @param string|string[] $value Search value, or array of values, one for each field in $fields * @param int $mode Search mode. Sum of rcube_addressbook::SEARCH_*. * @param bool $select True if records are requested in the result, false if count only * @param bool $nocount True to skip the count query (select only) * @param string|string[] $required Field or list of fields that cannot be empty * * @return rcube_result_set Contact records and 'count' value */ public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []) { $logger = Config::inst()->logger(); $mode = intval($mode); if (!is_array($required)) { $required = empty($required) ? [] : [$required]; } $logger->debug( "search(" . "FIELDS=[" . (is_array($fields) ? implode(", ", $fields) : $fields) . "], " . "VAL=" . (is_array($value) ? "[" . implode(", ", $value) . "]" : $value) . ", " . "MODE=$mode, SEL=$select, NOCOUNT=$nocount, " . "REQ=[" . implode(", ", $required) . "]" . ")" ); // (1) build the SQL WHERE clause for the fields to check against specific values ['filter' => $filter, 'postSearchMode' => $allMustMatch, 'postSearchFilter' => $postSearchFilter] = $this->buildDatabaseSearchFilter($fields, $value, $mode); // (2) Additionally, the search may request some fields not to be empty. // Compute the corresponding search clause and append to the existing one from (1) // this is an optional filter configured by the administrator that requires the given fields be not empty if ($this->config['require_always_email'] && !in_array('email', $required)) { $required[] = 'email'; } foreach (array_intersect($required, $this->table_cols) as $col) { $filter[] = new DbAndCondition(new DbOrCondition("!$col", "")); } $required = array_diff($required, $this->table_cols); // Post-searching in vCard data fields // we will search in all records and then build a where clause for their IDs if (!empty($postSearchFilter) || !empty($required)) { $ids = [ "0" ]; // 0 is never a valid ID - this is used to make sure the match values are a non-empty set // make sure we get all records - disable page constraint for list_records $pageBackup = $this->list_page; $pageSizeBackup = $this->page_size; try { $this->set_page(1); $this->set_pagesize(99999); // use initial filter to limit records number if possible $this->set_search_set($filter); $result = $this->list_records(); } finally { $this->list_page = $pageBackup; $this->page_size = $pageSizeBackup; } while ( /** @var ?SaveData $save_data */ $save_data = $result->next() ) { if ($this->checkPostSearchFilter($save_data, $required, $allMustMatch, $postSearchFilter, $mode)) { /** @var array{ID: string} $save_data */ $ids[] = $save_data["ID"]; } } $filter = [ new DbAndCondition(new DbOrCondition($this->primary_key, $ids)) ]; // when we know we have an empty result if (count($ids) < 2) { $this->set_search_set($filter); $result = new rcube_result_set(0, 0); $this->result = $result; return $result; } } $this->set_search_set($filter); if ($select) { $result = $this->list_records(null, 0, $nocount); } else { $result = $this->count(); $this->result = $result; } return $result; } /** * Count the number of contacts in the database matching the current filter criteria. * * The current filter criteria are defined by the search filter (see search()/set_search_set()), the currently * active group (see set_group()), and, if configured require_always_email, the required email property. * * @return rcube_result_set Result set with values for 'count' and 'first' */ public function count(): rcube_result_set { try { $numCards = $this->doCount(); return new rcube_result_set($numCards, ($this->list_page - 1) * $this->page_size); } catch (Exception $e) { $logger = Config::inst()->logger(); $logger->error(__METHOD__ . " exception: " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SEARCH, $e->getMessage()); return new rcube_result_set(); } } /** * Return the last result set * * @return ?rcube_result_set Current result set or NULL if nothing selected yet */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function get_result(): ?rcube_result_set { return $this->result; } /** * Get a specific contact record * * The result of the operation is internally cached for later retrieval using get_result(). * * @param mixed $id Record identifier(s) * @param bool $assoc True to return record as associative array, otherwise a result set is returned * * If an error occurs and an associative array is requested as result time, an empty array is returned. * * @return ($assoc is true ? (array{}|SaveDataFromDC) : rcube_result_set) Result object with all record fields */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function get_record($id, $assoc = false) { $infra = Config::inst(); $logger = $infra->logger(); try { $db = $infra->db(); $id = (string) $id; $logger->debug("get_record($id, $assoc)"); $davAbook = $this->getCardDavObj(); /** @var array{vcard: string} $contact */ $contact = $db->lookup(['id' => $id, "abook_id" => $this->id], ['vcard']); $vcard = $this->parseVCard($contact['vcard']); $save_data = $this->dataConverter->toRoundcube($vcard, $davAbook); $save_data['ID'] = $id; $this->result = new rcube_result_set(1); $this->result->add($save_data); return $assoc ? $save_data : $this->result; } catch (Exception $e) { $logger->error("Could not get contact $id: " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SEARCH, "Could not get contact $id"); return $assoc ? [] : new rcube_result_set(); } } /** * Set internal sort settings * * @param ?string $sort_col Sort column * @param ?string $sort_order Sort order */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function set_sort_order($sort_col, $sort_order = null): void { if (isset($sort_col) && key_exists($sort_col, $this->coltypes)) { $this->sort_col = $sort_col; } if (isset($sort_order)) { $this->sort_order = strtoupper($sort_order) == 'DESC' ? 'DESC' : 'ASC'; } } /** * Create a new contact record * * @param array $save_data Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * @param bool $check True to check for duplicates first * * @return string|false The created record ID on success, false on error */ public function insert($save_data, $check = false) { $infra = Config::inst(); $logger = $infra->logger(); /** @psalm-var SaveData $save_data */ try { $logger->info("insert(" . ($save_data["name"] ?? "no name") . ", $check)"); $db = $infra->db(); /** @psalm-var SaveData $save_data XXX temporary vimeo/psalm#8980 workaround */ $vcard = $this->dataConverter->fromRoundcube($save_data); $davAbook = $this->getCardDavObj(); [ 'uri' => $uri ] = $davAbook->createCard($vcard); $this->resync(); /** * We preferably check the UID. But as some CardDAV services (i.e. Google) change the UID in the VCard to a * server-side one, we fall back to searching by URL if the UID search returned no results. * @psalm-var list $contact */ $contact = $db->get(['cuid' => (string) $vcard->UID, "abook_id" => $this->id], ['id']); if (empty($contact)) { /** @var array{id: string} $contact */ $contact = $db->lookup(['uri' => $uri, "abook_id" => $this->id], ['id']); } else { $contact = $contact[0]; } return $contact["id"]; } catch (Exception $e) { $this->set_error(rcube_addressbook::ERROR_SAVING, $e->getMessage()); } return false; } /** * Update a specific contact record * * @param mixed $id Record identifier * @param array $save_cols Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * * @return string|bool On success if ID has been changed returns ID, otherwise True, False on error */ public function update($id, $save_cols) { $infra = Config::inst(); $logger = $infra->logger(); $id = (string) $id; /** @var SaveData $save_cols */ try { $logger->info("update(" . ($save_cols["name"] ?? "no name") . ", ID=$id)"); /** @psalm-var SaveData $save_cols XXX temporary vimeo/psalm#8980 workaround */ $db = $infra->db(); /** * get current DB data * @var array{etag: string, uri: string, showas: string, vcard: string} $contact */ $contact = $db->lookup(["id" => $id, "abook_id" => $this->id], ["uri", "etag", "vcard", "showas"]); $save_cols['showas'] = $contact['showas']; // create vcard from current DB data to be updated with the new data $vcard = $this->parseVCard($contact['vcard']); $vcard = $this->dataConverter->fromRoundcube($save_cols, $vcard); $davAbook = $this->getCardDavObj(); $etag = $davAbook->updateCard($contact['uri'], $vcard, $contact['etag']); $this->resync(); // if the ETag is null, the update failed because of a precondition check; we performed a resync above, so a // subsequent update is more likely to succeed if ($etag === null) { $rc = $infra->rc(); $this->set_error(rcube_addressbook::ERROR_SAVING, $rc->locText('Be_etagmismatch_msg')); return false; } return true; } catch (Exception $e) { $this->set_error(rcube_addressbook::ERROR_SAVING, $e->getMessage()); $logger->error("Failed to update contact $id: " . $e->getMessage()); return false; } } /** * Mark one or more contact records as deleted * * @param array $ids Record identifiers * @param bool $force Remove records irreversible (see self::undelete) * * @return int|false Number of removed records, False on failure */ public function delete($ids, $force = true) { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); /** @var list $ids */ $deleted = 0; $logger->info("delete([" . implode(",", $ids) . "])"); try { $davAbook = $this->getCardDavObj(); $db->startTransaction(); /** @var list $contacts */ $contacts = $db->get(['id' => $ids, "abook_id" => $this->id], ['id', 'cuid', 'uri']); // make sure we only have contacts in $ids that belong to this addressbook $ids = array_column($contacts, "id"); $contact_cuids = array_column($contacts, "cuid"); /** * remove contacts from VCard based groups - get groups that the contacts are members of * @var list $groupids */ $groupids = array_column($db->get(['contact_id' => $ids], ['group_id'], 'group_user'), "group_id"); if (!empty($groupids)) { /** @var list $groups */ $groups = $db->get(['id' => $groupids], ["id", "etag", "uri", "vcard"], "groups"); foreach ($groups as $group) { if (isset($group["vcard"])) { /** @var array{id: numeric-string, uri: string, etag: string, vcard: string} $group */ $this->removeContactsFromVCardBasedGroup($contact_cuids, $group); } } } $db->endTransaction(); // delete the contact cards from the server foreach ($contacts as $contact) { $davAbook->deleteCard($contact['uri']); ++$deleted; } // and sync back the changes to the cache $this->resync(); } catch (Exception $e) { $this->set_error(rcube_addressbook::ERROR_SAVING, $e->getMessage()); $logger->error("Failed to delete contacts [" . implode(",", $ids) . "]:" . $e->getMessage()); $db->rollbackTransaction(); return false; } return $deleted; } /** * Mark all records in database as deleted * * @param bool $with_groups Remove also groups */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function delete_all($with_groups = false): void { $infra = Config::inst(); $logger = $infra->logger(); try { $db = $infra->db(); $logger->info("delete_all($with_groups)"); $davAbook = $this->getCardDavObj(); $abook_id = $this->id; /** * first remove / clear KIND=group vcard-based groups * @var list $vcard_groups */ $vcard_groups = $db->get(["abook_id" => $abook_id, "!vcard" => null], ["uri", "vcard", "etag"], "groups"); foreach ($vcard_groups as $vcard_group) { if ($with_groups) { $davAbook->deleteCard($vcard_group["uri"]); } else { // create vcard from current DB data to be updated with the new data $vcard = $this->parseVCard($vcard_group['vcard']); $vcard->remove('X-ADDRESSBOOKSERVER-MEMBER'); $davAbook->updateCard($vcard_group['uri'], $vcard, $vcard_group['etag']); } } /** * now delete all contact cards * @var list $contacts */ $contacts = $db->get(["abook_id" => $abook_id], ["uri"]); foreach ($contacts as $contact) { $davAbook->deleteCard($contact["uri"]); } // and sync the changes back $this->resync(); // CATEGORIES-type groups are still inside the DB - remove if requested $db->delete(["abook_id" => $abook_id], "groups"); } catch (Exception $e) { $logger->error("delete_all: " . $e->getMessage()); $this->set_error(self::ERROR_SAVING, $e->getMessage()); } } /** * Sets/clears the current group. * * This affects the contact set considered when using the count(), list_records() and search() operations to those * contacts that belong to the given group. If no current group is set, all contacts in the addressbook are * considered. * * This filter mechanism is applied in addition to other filter mechanisms, see the description of the count() * operation. * * @param null|0|string $group_id Database identifier of the group. 0/"0"/null to reset the group filter. */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function set_group($group_id): void { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->debug("set_group($group_id)"); if ($group_id) { $db = $infra->db(); // check for valid ID with the database - this throws an exception if the group cannot be found $db->lookup(["id" => $group_id, "abook_id" => $this->id], ["id"], "groups"); $this->group_id = $group_id; } else { $this->group_id = null; } } catch (Exception $e) { $logger->error("set_group($group_id): " . $e->getMessage()); } } /** * List all active contact groups of this source * * @param ?string $search Optional search string to match group name * @param int $mode Search mode. Sum of self::SEARCH_* * * @return list List of contact groups, each a hash array */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function list_groups($search = null, $mode = 0): array { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->debug("list_groups(" . ($search ?? 'null') . ", $mode)"); $db = $infra->db(); $conditions = ["abook_id" => $this->id]; if ($search !== null) { if ($mode & rcube_addressbook::SEARCH_STRICT) { $conditions['name'] = $search; } elseif ($mode & rcube_addressbook::SEARCH_PREFIX) { $conditions['%name'] = "$search%"; } else { $conditions['%name'] = "%$search%"; } } /** @var list $groups */ $groups = $db->get($conditions, ["id", "name"], "groups"); $groups = array_map( /** * @param array{id: string, name: string} $grp * @return GroupSaveData */ function (array $grp): array { $grp['ID'] = $grp['id']; return $grp; }, $groups ); usort( $groups, /** * @param array{name: string, ...} $g1 * @param array{name: string, ...} $g2 */ function (array $g1, array $g2): int { return strcasecmp($g1["name"], $g2["name"]); } ); return $groups; } catch (Exception $e) { $logger->error(__METHOD__ . "(" . ($search ?? 'null') . ", $mode) exception: " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SEARCH, $e->getMessage()); return []; } } /** * Get group properties such as name and email address(es) * * @param string $group_id Group identifier * * @return ?GroupSaveData Group properties as hash array, null in case of error. */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function get_group($group_id): ?array { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->debug("get_group($group_id)"); $db = $infra->db(); // As of 1.4.6, roundcube is interested in name and email properties of a group, // i.e. if the group as a distribution list had an email address of its own. Otherwise, it will fall back // to getting the individual members' addresses /** @var array{id: numeric-string, name: string} $result */ $result = $db->lookup(["id" => $group_id, "abook_id" => $this->id], ['id', 'name'], 'groups'); $result['ID'] = $result['id']; return $result; } catch (Exception $e) { $logger->error("get_group($group_id): Could not get group: " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SEARCH, "Could not get group $group_id"); } return null; } /** * Create a contact group with the given name * * @param string $name The group name * * @return array|false False on error, array with record props in success */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function create_group($name) { $infra = Config::inst(); $logger = $infra->logger(); try { $logger->info("create_group($name)"); $db = $infra->db(); $save_data = [ 'name' => $name, 'kind' => 'group' ]; if ($this->config['use_categories']) { $groupid = $db->storeGroup($this->id, $save_data); return [ 'id' => $groupid, 'name' => $name ]; } else { $davAbook = $this->getCardDavObj(); $vcard = $this->dataConverter->fromRoundcube($save_data); $davAbook->createCard($vcard); $this->resync(); return $db->lookup(['cuid' => (string) $vcard->UID], ['id', 'name'], 'groups'); } } catch (Exception $e) { $logger->error("create_group($name): " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SAVING, $e->getMessage()); } return false; } /** * Delete the given group and all linked group members * * @param string $group_id Group identifier * * @return bool True on success, false if no data was changed */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function delete_group($group_id): bool { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); try { $logger->info("delete_group($group_id)"); $davAbook = $this->getCardDavObj(); $db->startTransaction(false); /** @var array{name: string, uri: ?string} $group */ $group = $db->lookup(["id" => $group_id, "abook_id" => $this->id], ['name', 'uri'], 'groups'); if (isset($group["uri"])) { // KIND=group VCard-based group $davAbook->deleteCard($group["uri"]); } else { // CATEGORIES-type group $groupname = $group["name"]; $contact_ids = $this->getContactIdsForGroup($group_id); if (empty($contact_ids)) { // will not be deleted by sync, delete right now $db->delete(["id" => $group_id, "abook_id" => $this->id], "groups"); } else { $this->adjustContactCategories( $contact_ids, /** @param list $groups */ function (array &$groups, string $_contact_id) use ($groupname): bool { return self::stringsAddRemove($groups, [], [$groupname]); } ); } } $db->endTransaction(); $this->resync(); return true; } catch (Exception $e) { $logger->error("delete_group($group_id): " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SAVING, $e->getMessage()); $db->rollbackTransaction(); } return false; } /** * Rename a specific contact group * * @param string $group_id Group identifier * @param string $newname New name to set for this group * @param string &$newid New group identifier (if changed, otherwise don't set) * * @return string|false New name on success, false if no data was changed */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function rename_group($group_id, $newname, &$newid) { $infra = Config::inst(); $logger = $infra->logger(); try { $ret = $newname; $logger->info("rename_group($group_id, $newname)"); $db = $infra->db(); $davAbook = $this->getCardDavObj(); /** @var array{uri: ?string, name: string, etag: ?string, vcard: ?string} $group */ $group = $db->lookup( ["id" => $group_id, "abook_id" => $this->id], ['uri', 'name', 'etag', 'vcard'], 'groups' ); if (isset($group["uri"])) { // KIND=group VCard-based group /** @var array{uri: string, name: string, etag: string, vcard: string} $group */ $vcard = $this->parseVCard($group["vcard"]); $vcard->FN = $newname; $vcard->N = [$newname,"","","",""]; if ($davAbook->updateCard($group["uri"], $vcard, $group["etag"]) === null) { $rc = $infra->rc(); $this->set_error(rcube_addressbook::ERROR_SAVING, $rc->locText('Be_etagmismatch_msg')); $ret = false; } } else { // CATEGORIES-type group $oldname = $group["name"]; $contact_ids = $this->getContactIdsForGroup($group_id); if (empty($contact_ids)) { // rename empty group in DB $db->update(["id" => $group_id, "abook_id" => $this->id], ["name"], [$newname], "groups"); } else { $this->adjustContactCategories( $contact_ids, /** @param list $groups */ function (array &$groups, string $_contact_id) use ($oldname, $newname): bool { return self::stringsAddRemove($groups, [ $newname ], [ $oldname ]); } ); // resync will insert the contact assignments as a new group } } $this->resync(); return $ret; } catch (Exception $e) { $logger->error("rename_group($group_id, $newname): " . $e->getMessage()); $this->set_error(rcube_addressbook::ERROR_SAVING, $e->getMessage()); } return false; } /** * Add the given contact records a certain group * * @param string $group_id Group identifier * @param array|string $ids List of contact identifiers to be added * * @return int Number of contacts added */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function add_to_group($group_id, $ids): int { /** @var list|string $ids */ $added = 0; $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); try { $davAbook = $this->getCardDavObj(); if (is_string($ids)) { $ids_string = $ids; /** @psalm-var list $ids */ $ids = explode(self::SEPARATOR, $ids_string); } $db->startTransaction(); /** * get current DB data for contacts to add * @var array{uri: ?string, name: string, etag: ?string, vcard: ?string} $group */ $group = $db->lookup( ["id" => $group_id, "abook_id" => $this->id], ['name', 'uri', 'etag', 'vcard'], 'groups' ); /** * get contacts that already belong to the group * @psalm-var list $currentMembers */ $currentMembers = array_column( $db->get(['group_id' => $group_id], ['contact_id'], 'group_user'), 'contact_id' ); // remove ids of contacts that already belong to the group $ids = array_values(array_diff($ids, $currentMembers)); if (empty($ids)) { return 0; } // if vcard is set, this group is based on a KIND=group VCard if (isset($group['vcard'])) { /** @var list $contacts */ $contacts = $db->get(["id" => $ids, "abook_id" => $this->id], ["id", "cuid"]); $db->endTransaction(); /** @var array{uri: string, name: string, etag: string, vcard: string} $group */ // create vcard from current DB data to be updated with the new data $vcard = $this->parseVCard($group['vcard']); foreach ($contacts as $contact) { try { $vcard->add('X-ADDRESSBOOKSERVER-MEMBER', "urn:uuid:" . $contact['cuid']); ++$added; } catch (Exception $e) { $logger->warning("add_to_group: Contact with ID {$contact['cuid']} not found in DB"); } } if ($davAbook->updateCard($group['uri'], $vcard, $group['etag']) === null) { $added = 0; $rc = $infra->rc(); $this->set_error(rcube_addressbook::ERROR_SAVING, $rc->locText('Be_etagmismatch_msg')); } // if vcard is not set, this group comes from the CATEGORIES property of the contacts it comprises } else { $db->endTransaction(); $groupname = $group["name"]; $this->adjustContactCategories( $ids, // unfiltered ids allowed in adjustContactCategories() /** @param list $groups */ function (array &$groups, string $contact_id) use ($logger, $groupname, &$added): bool { /** @var int $added */ if (self::stringsAddRemove($groups, [ $groupname ])) { $logger->debug("Adding contact $contact_id to category $groupname"); ++$added; return true; } else { $logger->debug("Contact $contact_id already belongs to category $groupname"); } return false; } ); /** @var int $added Reference from the closure appears to confuse psalm */ } $this->resync(); } catch (Exception $e) { $logger->error("add_to_group: " . $e->getMessage()); $this->set_error(self::ERROR_SAVING, $e->getMessage()); $db->rollbackTransaction(); } return $added; } /** * Remove the given contact records from a certain group * * @param string $group_id Group identifier * @param array|string $ids List of contact identifiers to be removed * * @return int Number of deleted group members */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function remove_from_group($group_id, $ids): int { /** @var list|string $ids */ $deleted = 0; $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); try { if (!is_array($ids)) { $ids = explode(self::SEPARATOR, $ids); } $logger->info("remove_from_group($group_id, [" . implode(",", $ids) . "])"); $db->startTransaction(); /** * get current DB data * @var array{name: string, uri: ?string, etag: ?string, vcard: ?string} $group */ $group = $db->lookup( ["id" => $group_id, "abook_id" => $this->id], ['name', 'uri', 'etag', 'vcard'], 'groups' ); // if vcard is set, this group is based on a KIND=group VCard if (isset($group['vcard'])) { /** @var list $contacts */ $contacts = $db->get(["id" => $ids, "abook_id" => $this->id], ["id", "cuid"]); $db->endTransaction(); /** @var array{name: string, uri: string, etag: string, vcard: string} $group */ $deleted = $this->removeContactsFromVCardBasedGroup(array_column($contacts, "cuid"), $group); // if vcard is not set, this group comes from the CATEGORIES property of the contacts it comprises } else { $db->endTransaction(); $groupname = $group["name"]; $this->adjustContactCategories( $ids, // unfiltered ids allowed in adjustContactCategories() /** @param list $groups */ function (array &$groups, string $contact_id) use ($logger, $groupname, &$deleted): bool { /** @var int $deleted */ if (self::stringsAddRemove($groups, [], [$groupname])) { $logger->debug("Removing contact $contact_id from category $groupname"); ++$deleted; return true; } else { $logger->debug("Contact $contact_id not a member category $groupname - skipped"); } return false; } ); /** @psalm-var int $deleted Reference from the closure appears to confuse psalm */ } $this->resync(); } catch (Exception $e) { $logger->error("remove_from_group: " . $e->getMessage()); $this->set_error(self::ERROR_SAVING, $e->getMessage()); $db->rollbackTransaction(); return 0; } return $deleted; } /** * Get group assignments of a specific contact record * * @param mixed $id Record identifier * * @return array List of assigned groups as ID=>Name pairs */ // phpcs:ignore PSR1.Methods.CamelCapsMethodName -- method name defined by rcube_addressbook class public function get_record_groups($id): array { $id = (string) $id; $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); try { $logger->debug("get_record_groups($id)"); /** @var list $groupIds */ $groupIds = array_column($db->get(['contact_id' => $id], ['group_id'], 'group_user'), 'group_id'); if (empty($groupIds)) { $groups = []; } else { /** @var array $groups */ $groups = array_column( $db->get(['id' => $groupIds, 'abook_id' => $this->id], ['id', 'name'], 'groups'), 'name', 'id' ); } return $groups; } catch (Exception $e) { $logger->error("get_record_groups($id): " . $e->getMessage()); $this->set_error(self::ERROR_SEARCH, $e->getMessage()); return []; } } /************************************************************************ * PUBLIC PLUGIN INTERNAL FUNCTIONS ***********************************************************************/ public function getId(): string { return $this->id; } /** * Returns refresh time for the addressbook in seconds * * @return int refresh time in seconds */ public function getRefreshTime(): int { return intval($this->config['refresh_time']); } /** * Synchronizes the local card store with the CardDAV server. * * @return int The duration in seconds that the sync took */ public function resync(): int { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); $start_refresh = time(); $davAbook = $this->getCardDavObj(); $synchandler = new SyncHandlerRoundcube($this, $this->dataConverter, $davAbook); $syncmgr = $infra->makeSyncService(); $sync_token = $syncmgr->synchronize($davAbook, $synchandler, [], $this->config['sync_token']); $this->config['sync_token'] = $sync_token; $this->config["last_updated"] = (string) time(); $db->update( $this->id, ["last_updated", "sync_token"], [$this->config["last_updated"], $sync_token], "addressbooks" ); $duration = time() - $start_refresh; $logger->info("sync of addressbook $this->id ({$this->get_name()}) took $duration seconds"); return $duration; } /** * Determines the due time for the next resync of this addressbook relative to the current time. * * @return int Seconds until next resync is due (negative if resync due time is in the past) */ public function checkResyncDue(): int { $ts_now = time(); $ts_nextupd = intval($this->config["last_updated"]) + intval($this->config["refresh_time"]); return ($ts_nextupd - $ts_now); } /** * Adds/removes strings from an array. * * The function removes all whitespace-only strings plus the strings in $rm from $in. * It then adds all the strings in $add to $in, except if they are already contained in $in. * * @param list $in The array to modify (passed by reference) * @param list $add The list of strings to add to $in * @param list $rm The list of strings to remove from $in * @return bool True if changes were made to $in. */ public static function stringsAddRemove(array &$in, array $add = [], array $rm = []): bool { $changes = false; $result = []; // first remove all whitespace entries and the entries in $rm foreach ($in as $v) { if ((!empty(trim($v))) && (!in_array($v, $rm, true))) { $result[] = $v; } else { $changes = true; } } foreach ($add as $a) { if (!in_array($a, $result, true)) { $result[] = $a; $changes = true; } } if ($changes) { $in = $result; } return $changes; } /************************************************************************ * PRIVATE FUNCTIONS ***********************************************************************/ /** * Queries the records (or a subset, if requested) of the current page of the total record set matching the current * filter conditions. * * The record sets are added to the given result set. * * @param ?list $cols List of contact fields to provide, null means all. * @return int The number of records added to the result set. */ private function listRecordsReadDB(?array $cols, int $subset, rcube_result_set $result): int { $infra = Config::inst(); $logger = $infra->logger(); $db = $infra->db(); // Subset further narrows the records contained within the active page. It must therefore not exceed the // page size; it should not happen, but if it did we limit subset to the page size here if (abs($subset) > $this->page_size) { $subset = ($subset < 0) ? -$this->page_size : $this->page_size; } // determine result subset needed $firstrow = ($subset >= 0) ? $result->first : ($result->first + $this->page_size + $subset); $numrows = $subset ? abs($subset) : $this->page_size; // determine whether we have to parse the vcard or if only db cols are requested if (isset($cols)) { if (count(array_intersect($cols, $this->table_cols)) < count($cols)) { $dbattr = ['vcard']; } else { $dbattr = $cols; } } else { $dbattr = ['vcard']; } $sort_column = $this->sort_col; if ($this->sort_order == "DESC") { $sort_column = "!$sort_column"; } $logger->debug("listRecordsReadDB " . join(',', $dbattr) . " [$firstrow, $numrows] ORD($sort_column)"); $conditions = $this->currentFilterConditions(); if (isset($conditions)) { /** @var list> $contacts */ $contacts = $db->get( $conditions, array_values(array_unique(array_merge(["id", "name"], $dbattr))), 'contacts', [ 'limit' => [ $firstrow, $numrows ], 'order' => [ $sort_column ] ] ); } else { $contacts = []; } // FIXME ORDER BY (CASE WHEN showas='COMPANY' THEN organization ELSE " . $sort_column . " END) $dc = $this->dataConverter; $resultCount = 0; foreach ($contacts as $contact) { $save_data = []; if (isset($contact['vcard'])) { $vcf = $contact['vcard']; try { $vcard = $this->parseVCard($vcf); $davAbook = $this->getCardDavObj(); $save_data = $dc->toRoundcube($vcard, $davAbook); } catch (Exception $e) { $logger->warning("Couldn't parse vcard $vcf"); continue; } } elseif (isset($cols)) { // NOTE always true at this point, but difficult to know for psalm foreach ($cols as $col) { $colval = $contact[$col] ?? ""; if (strlen($colval) > 0) { if ($dc->isMultivalueProperty($col)) { $save_data[$col] = explode(AbstractDatabase::MULTIVAL_SEP, $colval); } else { $save_data[$col] = $colval; } } } } /** @var SaveData $save_data */ $save_data['ID'] = $contact['id']; ++$resultCount; $result->add($save_data); } return $resultCount; } /** * This function builds an array of search criteria for the Database search for matching the requested * contact search fields against fields of the carddav_contacts table according to the given search $mode. * * Only some fields are contained as individual columns in the carddav_contacts table, as indicated by * $this->table_cols. The remaining fields need to be searched for in the VCards, which are a single column in the * database. Therefore, the database search can only match against the entire VCard, but will not check if the * correct property of the VCard contained the value. Thus, if such search fields are queried, the DB result needs * to be post-filtered with a check for these particular fields against the VCard properties. * * This function returns an associative array with entries: * "filter": An array of DbAndCondition for use with the AbstractDatabase::get() function. * "postSearchMode": true if all conditions must match ("AND"), false if a single match is sufficient ("OR") * "postSearchFilter": An array of two-element arrays, each with: [ column name, lower-cased search value ] * * @param string|string[] $fields Field names to search in * @param string|string[] $value Search value, or array of values, one for each field in $fields * @param int $mode Matching mode, see Addressbook::search() for extended description. * @return array{filter: list, postSearchMode: bool, postSearchFilter: list} */ private function buildDatabaseSearchFilter($fields, $value, int $mode): array { $conditions = []; $postSearchMode = false; $postSearchFilter = []; if (($fields == "ID") || ($fields == $this->primary_key)) { // direct ID search $ids = is_array($value) ? $value : explode(self::SEPARATOR, $value); $conditions[] = new DbAndCondition(new DbOrCondition($this->primary_key, $ids)); } elseif (is_array($value)) { $postSearchMode = true; // Advanced search foreach ((array) $fields as $idx => $col) { // the value to check this field for $fValue = $value[$idx]; if (empty($fValue)) { continue; } if (in_array($col, $this->table_cols)) { // table column // Note: we don't need to add this column to the post match filter, because it is already // determined by the database search that the condition is fulfilled $conditions[] = $this->rcSearchCondition($mode, $col, $fValue); } else { // vCard field $conditions[] = $this->rcSearchCondition(rcube_addressbook::SEARCH_ALL, 'vcard', $fValue); $postSearchFilter[] = [$col, mb_strtolower($fValue)]; } } } elseif ($fields == '*') { // search all fields $conditions[] = $this->rcSearchCondition(rcube_addressbook::SEARCH_ALL, 'vcard', $value); $postSearchFilter[] = ['*', mb_strtolower($value)]; } else { $andCond = new DbAndCondition(); // search given fields foreach ((array) $fields as $col) { if (in_array($col, $this->table_cols)) { // table column $andCond->append($this->rcSearchCondition($mode, $col, $value)); } else { // vCard field $andCond->append($this->rcSearchCondition(rcube_addressbook::SEARCH_ALL, 'vcard', $value)); } // Note: because a match against any column is sufficient, we must add all columns to the post search // filter. Otherwise, cards that match at DB columns but none of the vcard columns would be discarded by // the post search filter $postSearchFilter[] = [$col, mb_strtolower($value)]; } $conditions[] = $andCond; } return ['filter' => $conditions, 'postSearchMode' => $postSearchMode, 'postSearchFilter' => $postSearchFilter]; } /** * Produces a DbAndCondition resembling a roundcube search condition passed to the search() function. * * @param int $mode The search mode as given to search() * @param string $col The database column so search in * @param string $val The value to match for, according to the search mode */ private function rcSearchCondition(int $mode, string $col, string $val): DbAndCondition { $cond = new DbAndCondition(); $multi = (($col != "vcard") && $this->dataConverter->isMultivalueProperty($col)); $SEP = AbstractDatabase::MULTIVAL_SEP; if ($mode & rcube_addressbook::SEARCH_STRICT) { // exact match $cond->add("%$col", $val); if ($multi) { $cond->add("%$col", "$val$SEP%") // line beginning match 'name@domain.com, %' ->add("%$col", "%$SEP$val$SEP%") // middle match '%, name@domain.com, %' ->add("%$col", "%$SEP$val"); // line end match '%, name@domain.com' } } elseif ($mode & rcube_addressbook::SEARCH_PREFIX) { // prefix match (abc*) $cond->add("%$col", "$val%"); if ($multi) { $cond->add("%$col", "%$SEP$val%"); // middle/end match '%, name%' } } else { // "contains" match (*abc*) $cond->add("%$col", "%$val%"); } return $cond; } /** * Determines and returns the number of cards matching the current search criteria. */ private function doCount(): int { $numCards = 0; $conditions = $this->currentFilterConditions(); if (isset($conditions)) { $db = Config::inst()->db(); $result = $db->get($conditions, [], 'contacts', [ 'count' => true ]); $numCards = intval($result[0]['*']); } return $numCards; } private function parseVCard(string $vcf): VCard { // create vcard from current DB data to be updated with the new data $vcard = VObject\Reader::read($vcf, VObject\Reader::OPTION_FORGIVING); if (!($vcard instanceof VCard)) { throw new Exception("parseVCard: parsing of string did not result in a VCard object: $vcf"); } return $vcard; } /** * Removes a list of contacts from a KIND=group VCard-based group and updates the group on the server. * * An update of the card on the server will only be performed if members have actually been removed from the VCard, * i.e. the function returns a value greater than 0. * * @param list $contact_cuids The VCard UIDs of the contacts to remove from the group. * @param array{etag: string, uri: string, vcard: string, ...} $group Save data for the group. * * @return int The number of members actually removed from the group. */ private function removeContactsFromVCardBasedGroup(array $contact_cuids, array $group): int { $deleted = 0; // create vcard from current DB data to be updated with the new data $vcard = $this->parseVCard($group["vcard"]); foreach ($contact_cuids as $cuid) { $search_for = "urn:uuid:$cuid"; $found = false; /** @var VObject\Property $member */ foreach (($vcard->{'X-ADDRESSBOOKSERVER-MEMBER'} ?? []) as $member) { if ($member == $search_for) { $vcard->remove($member); $found = true; // we don't break here - just in case the member is listed several times in the VCard } } if ($found) { ++$deleted; } } if ($deleted > 0) { $davAbook = $this->getCardDavObj(); if ($davAbook->updateCard($group['uri'], $vcard, $group['etag']) === null) { $deleted = 0; } } return $deleted; } /** * Creates the AddressbookCollection object of the CardDavClient library. */ public function getCardDavObj(): AddressbookCollection { if (!isset($this->davAbook)) { $infra = Config::inst(); $url = $this->config["url"]; $davAbook = $infra->makeWebDavResource($url, $this->account); if ($davAbook instanceof AddressbookCollection) { $this->davAbook = $davAbook; } else { throw new Exception("No CardDAV addressbook found at $url"); } } return $this->davAbook; } /** * Provides an array of the contact database ids that belong to the given group. * * @param string $groupid The database ID of the group whose contacts shall be queried. * * @return list An array of the group's contacts' database IDs. */ private function getContactIdsForGroup(string $groupid): array { $infra = Config::inst(); $db = $infra->db(); /** @var list $records */ $records = $db->get(['group_id' => $groupid], ['contact_id'], 'group_user'); return array_column($records, "contact_id"); } /** * Adjusts the CATEGORIES property of a list of contacts using a given callback function and, if changed, stores the * changed VCard to the server. * * @param list $contact_ids A list of contact database IDs for that CATEGORIES should be adapted * @param callable(list, string) $callback * A callback function, that performs the adjustment of the CATEGORIES values. It is * called for each contact with two parameters: The first is an array of the values of the * CATEGORIES property, which should be taken by reference and modified in place. The * second is the database id of the contact the callback is called for. The callback shall * return true if the array was modified and the VCard shall be updated on the server, * false if no change was done and no update is necessary. * */ private function adjustContactCategories(array $contact_ids, callable $callback): void { $davAbook = $this->getCardDavObj(); $infra = Config::inst(); $db = $infra->db(); /** @var list $contacts */ $contacts = $db->get(["id" => $contact_ids, "abook_id" => $this->id], ["id", "etag", "uri", "vcard"]); foreach ($contacts as $contact) { $vcard = $this->parseVCard($contact['vcard']); $groups = []; if (isset($vcard->{"CATEGORIES"})) { /** @var list $groups */ $groups = $vcard->CATEGORIES->getParts(); } if ($callback($groups, $contact["id"]) !== false) { if (count($groups) > 0) { $vcard->CATEGORIES = $groups; } else { unset($vcard->CATEGORIES); } $davAbook->updateCard($contact['uri'], $vcard, $contact['etag']); } } } /** * Determines the AbstractDatabase::get() contact filter conditions. * * It must consider: * - Always constrain list to current addressbook * - The required non-empty email configured by the admin ($this->config['require_always_email']) * - A search filter set by roundcube ($this->filter) * - A currently selected group ($this->group_id) * * @return ?list Null if the current filter conditions result in an empty contact result. */ private function currentFilterConditions(): ?array { $conditions = $this->filter; $conditions[] = new DbAndCondition(new DbOrCondition("abook_id", $this->id)); if ($this->config['require_always_email']) { $conditions[] = new DbAndCondition(new DbOrCondition("!email", "")); $conditions[] = new DbAndCondition(new DbOrCondition("!email", null)); } // TODO Better if we could handle this without a separate SQL query here, but requires join or sub-query if ($this->group_id) { $contactsInGroup = $this->getContactIdsForGroup((string) $this->group_id); if (empty($contactsInGroup)) { $conditions = null; } else { $conditions[] = new DbAndCondition(new DbOrCondition("id", $contactsInGroup)); } } return $conditions; } /** * Checks the post-filter conditions on a contact. * * Background: Some search conditions cannot be reliably filtered using the database query. This is the case if the * searched contact attribute is not stored as a separate database column but only found inside the vcard. In this * case, we will perform a pre-filtering in the database query only to check if the vcard as a whole matches the * search condition, but post-filtering is needed to check whether the match actually occurs in the correct * attribute. * * This function checks these post filter condition on a single contact that was provided by the database after * pre-filtering using the database query. * * @param SaveData $save_data The contact data to check * @param string[] $required A list of contact attributes that must not be empty * @param bool $allMustMatch Indicates if all post filter conditions must match, or if a single match is sufficient. * @param list $postSearchFilter The post filter conditions as pairs of attribute name and * match value * @param int $mode The roundcube search mode. * * @see search() */ private function checkPostSearchFilter( array $save_data, array $required, bool $allMustMatch, array $postSearchFilter, int $mode ): bool { // normalize the attributes with subtype (e.g. email:home) to the generic attribute (e.g. email) foreach (array_keys($save_data) as $attr) { $colonPos = strpos($attr, ':'); if ($colonPos !== false) { $genAttr = substr($attr, 0, $colonPos); if (isset($save_data[$genAttr])) { $save_data[$genAttr] = array_merge((array) $save_data[$genAttr], (array) $save_data[$attr]); } else { $save_data[$genAttr] = (array) $save_data[$attr]; } unset($save_data[$attr]); } } // check that all the required fields are not empty foreach ($required as $requiredField) { if (!isset($save_data[$requiredField]) || $save_data[$requiredField] == "") { return false; } } // normalize address structured data to string if (isset($save_data['address'])) { $mergedAddrs = []; /** @psalm-var list $save_data['address'] */ foreach ($save_data['address'] as $addrStruct) { $mergedAddrs[] = implode(' ', $addrStruct); } $save_data['address'] = $mergedAddrs; } $filterMatches = 0; foreach ($postSearchFilter as $psfilter) { [ $col, $val ] = $psfilter; $psFilterMatched = false; if ($col == '*') { // any contact attribute must match $val foreach ($save_data as $k => $v) { // Skip photo/vcard to avoid download - matching against photo is no meaningful use case if ($k !== "photo" && $k !== "vcard" && strpos($k, "_carddav_") !== 0) { $v = is_array($v) ? $v : (string) $v; if ($this->compare_search_value($k, $v, $val, $mode)) { $psFilterMatched = true; break; } } } } elseif (isset($save_data[$col])) { $sdVal = is_array($save_data[$col]) ? $save_data[$col] : (string) $save_data[$col]; $psFilterMatched = $this->compare_search_value($col, $sdVal, $val, $mode); } if ($psFilterMatched) { ++$filterMatches; } if (!$allMustMatch && $psFilterMatched) { return true; } elseif ($allMustMatch && !$psFilterMatched) { return false; } } return $filterMatches > 0; } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120