diff --git a/addressbook/inc/class.addressbook_hooks.inc.php b/addressbook/inc/class.addressbook_hooks.inc.php
index 2a617dce12..a8a5296c85 100644
--- a/addressbook/inc/class.addressbook_hooks.inc.php
+++ b/addressbook/inc/class.addressbook_hooks.inc.php
@@ -150,6 +150,11 @@ class addressbook_hooks
);
$contacts = new Api\Contacts();
$fileas_options = $contacts->fileas_options();
+ foreach(Api\Contacts\Storage::$duplicate_fields as $key => $label)
+ {
+ $duplicate_options[$key] = lang($label);
+ }
+
$settings['link_title'] = array(
'type' => 'select',
'label' => 'Link title for contacts show',
@@ -197,6 +202,26 @@ class addressbook_hooks
'admin' => false,
'default'=> 'org_name: n_family, n_given',
);
+ $settings['duplicate_fields'] = array(
+ 'type' => 'multiselect',
+ 'label' => 'Fields to check for duplicates',
+ 'name' => 'duplicate_fields',
+ 'values' => $duplicate_options,
+ 'help' => 'Fields to consider when looking for duplicate contacts.',
+ 'admin' => false,
+ 'default' => 'n_family, n_given, org_name, contact_email'
+ );
+ $settings['duplicate_threshold'] = array(
+ 'type' => 'input',
+ 'size' => 5,
+ 'label' => 'Duplicate threshold',
+ 'name' => 'duplicate_threshold',
+ 'help' => 'How many fields must match for the record to be considered a duplicate.',
+ 'xmlrpc' => True,
+ 'default'=> 3,
+ 'admin' => False
+ );
+
$crm_list_options = array(
'~edit~' => lang('Edit contact'),
'infolog' => lang('Open %1 CRM view', lang('infolog')),
diff --git a/addressbook/inc/class.addressbook_ui.inc.php b/addressbook/inc/class.addressbook_ui.inc.php
index e69bfd6577..fe84a281c9 100644
--- a/addressbook/inc/class.addressbook_ui.inc.php
+++ b/addressbook/inc/class.addressbook_ui.inc.php
@@ -85,10 +85,11 @@ class addressbook_ui extends addressbook_bo
$this->tmpl = new Etemplate();
- $this->org_views = array(
+ $this->grouped_views = array(
'org_name' => lang('Organisations'),
'org_name,adr_one_locality' => lang('Organisations by location'),
'org_name,org_unit' => lang('Organisations by departments'),
+ 'duplicates' => lang('Duplicates')
);
// make sure the hook for export_limit is registered
@@ -149,9 +150,10 @@ class addressbook_ui extends addressbook_bo
{
$msg = lang('You need to select some contacts first');
}
- elseif ($_content['nm']['action'] == 'view_org') // org-view via context menu
+ elseif ($_content['nm']['action'] == 'view_org' || $_content['nm']['action'] == 'view_duplicates')
{
- $_content['nm']['org_view'] = array_shift($_content['nm']['selected']);
+ // grouped view via context menu
+ $_content['nm']['grouped_view'] = array_shift($_content['nm']['selected']);
}
else
{
@@ -177,11 +179,11 @@ class addressbook_ui extends addressbook_bo
}
if ($_content['nm']['rows']['view']) // show all contacts of an organisation
{
- list($org_view) = each($_content['nm']['rows']['view']);
+ list($grouped_view) = each($_content['nm']['rows']['view']);
}
else
{
- $org_view = $_content['nm']['org_view'];
+ $grouped_view = $_content['nm']['grouped_view'];
}
$typeselection = $_content['nm']['col_filter']['tid'];
}
@@ -245,7 +247,7 @@ class addressbook_ui extends addressbook_bo
//'actions' => $this->get_actions(), // set on each request, as it depends on some filters
'row_id' => 'id',
'row_modified' => 'modified',
- 'is_parent' => 'org_count',
+ 'is_parent' => 'group_count',
'parent_id' => 'parent_id',
'favorites' => true,
'placeholder_actions' => array('add')
@@ -338,27 +340,25 @@ class addressbook_ui extends addressbook_bo
{
$this->tmpl->disableElement('nm[col_filter][tid]');
}
- // get the availible org-views plus the label of the contacts view of one org
- $sel_options['org_view'] = $this->org_views;
- if (isset($org_view))
+ // get the availible grouped-views plus the label of the contacts view of one group
+ $sel_options['grouped_view'] = $this->grouped_views;
+ if (isset($grouped_view))
{
- $content['nm']['org_view'] = $org_view;
+ $content['nm']['grouped_view'] = $grouped_view;
}
- $content['nm']['actions'] = $this->get_actions($content['nm']['col_filter']['tid'], $content['nm']['org_view']);
+ $content['nm']['actions'] = $this->get_actions($content['nm']['col_filter']['tid']);
- if (!isset($sel_options['org_view'][(string) $content['nm']['org_view']]))
+ if (!isset($sel_options['grouped_view'][(string) $content['nm']['grouped_view']]))
{
- $sel_options['org_view'] += $this->_get_org_name((string)$content['nm']['org_view']);
+ $sel_options['grouped_view'] += $this->_get_grouped_name((string)$content['nm']['grouped_view']);
}
- // unset the filters regarding organisations, when there is no organisation selected
- if (empty($sel_options['org_view'][(string) $content['nm']['org_view']]) || stripos($org_view,":") === false )
+ // unset the filters regarding grouped views, when there is no group selected
+ if (empty($sel_options['grouped_view'][(string) $content['nm']['grouped_view']]) || stripos($grouped_view,":") === false )
{
- unset($content['nm']['col_filter']['org_name']);
- unset($content['nm']['col_filter']['org_unit']);
- unset($content['nm']['col_filter']['adr_one_locality']);
+ $this->unset_grouped_filters($content['nm']);
}
- $content['nm']['org_view_label'] = $sel_options['org_view'][(string) $content['nm']['org_view']];
+ $content['nm']['grouped_view_label'] = $sel_options['grouped_view'][(string) $content['nm']['grouped_view']];
$this->tmpl->read($do_email ? 'addressbook.email' : 'addressbook.index');
return $this->tmpl->exec($do_email ? 'addressbook.addressbook_ui.emailpopup' : 'addressbook.addressbook_ui.index',
@@ -381,7 +381,7 @@ class addressbook_ui extends addressbook_bo
'allowOnMultiple' => false,
'group' => $group=1,
'onExecute' => 'javaScript:app.addressbook.view',
- 'disableClass' => 'contact_organisation',
+ 'enableClass' => 'contact_contact',
'hideOnDisabled' => true,
// Children added below
'children' => array(),
@@ -391,7 +391,7 @@ class addressbook_ui extends addressbook_bo
'caption' => 'Open',
'default' => $GLOBALS['egw_info']['user']['preferences']['addressbook']['crm_list'] == '~edit~',
'allowOnMultiple' => false,
- 'disableClass' => 'contact_organisation',
+ 'enableClass' => 'contact_contact',
'hideOnDisabled' => true,
'url' => 'menuaction=addressbook.addressbook_ui.edit&contact_id=$id',
'popup' => Link::get_registry('addressbook', 'edit_popup'),
@@ -400,7 +400,7 @@ class addressbook_ui extends addressbook_bo
'add' => array(
'caption' => 'Add',
'group' => $group,
- 'disableClass' => 'contact_organisation',
+ 'enableClass' => 'contact_contact',
'hideOnDisabled' => true,
'children' => array(
'new' => array(
@@ -445,20 +445,39 @@ class addressbook_ui extends addressbook_bo
'default' => true,
'allowOnMultiple' => false,
'group' => $group=1,
- 'disableClass' => 'contact_contact',
+ 'enableClass' => 'contact_organisation',
'hideOnDisabled' => true
),
'add_org' => array(
'caption' => 'Add',
'group' => $group,
'allowOnMultiple' => false,
- 'disableClass' => 'contact_contact',
+ 'enableClass' => 'contact_organisation',
'hideOnDisabled' => true,
'url' => 'menuaction=addressbook.addressbook_ui.edit&org=$id',
'popup' => Link::get_registry('addressbook', 'add_popup'),
),
);
+ // Duplicates view
+ $actions += array(
+ 'view_duplicates' => array(
+ 'caption' => 'View',
+ 'default' => true,
+ 'allowOnMultiple' => false,
+ 'group' => $group=1,
+ 'enableClass' => 'contact_duplicate',
+ 'hideOnDisabled' => true
+ ),
+ 'merge_duplicates' => array(
+ 'caption' => 'Merge duplicates',
+ 'group' => $group,
+ 'allowOnMultiple' => true,
+ 'enableClass' => 'contact_duplicate',
+ 'hideOnDisabled' => true
+ )
+ );
+
++$group; // other AB related stuff group: lists, AB's, categories
// categories submenu
$actions['cat'] = array(
@@ -587,6 +606,7 @@ class addressbook_ui extends addressbook_bo
'caption' => lang('View linked InfoLog entries'),
'icon' => 'infolog/navbar',
'onExecute' => 'javaScript:app.addressbook.view_infolog',
+ 'enableClass' => 'contact_contact',
'allowOnMultiple' => true,
'hideOnDisabled' => true,
),
@@ -607,6 +627,7 @@ class addressbook_ui extends addressbook_bo
'icon' => 'calendar/navbar',
'caption' => 'Calendar',
'group' => $group,
+ 'enableClass' => 'contact_contact',
'children' => array(
'calendar_view' => array(
'caption' => 'Show',
@@ -629,7 +650,7 @@ class addressbook_ui extends addressbook_bo
$actions['email'] = array(
'caption' => 'Email',
'icon' => 'mail/navbar',
- 'disableClass' => 'contact_organisation',
+ 'enableClass' => 'contact_contact',
'hideOnDisabled' => true,
'group' => $group,
'children' => array(
@@ -681,8 +702,8 @@ class addressbook_ui extends addressbook_bo
'url' => 'menuaction=filemanager.filemanager_ui.index&path=/apps/addressbook/$id&ajax=true',
'allowOnMultiple' => false,
'group' => $group,
- // disable for for org-views, as it needs contact-ids
- 'disableClass' => 'contact_organisation',
+ // disable for for group-views, as it needs contact-ids
+ 'enableClass' => 'contact_contact',
'hideOnMobile' => true
);
}
@@ -691,6 +712,7 @@ class addressbook_ui extends addressbook_bo
'caption' => 'GeoLocation',
'icon' => 'map',
'group' => ++$group,
+ 'enableClass' => 'contact_contact',
'children' => array (
'private' => array(
'caption' => 'Private Address',
@@ -713,6 +735,7 @@ class addressbook_ui extends addressbook_bo
$actions['export'] = array(
'caption' => 'Export',
'icon' => 'filesave',
+ 'enableClass' => 'contact_contact',
'group' => ++$group,
'children' => array(
'csv' => array(
@@ -742,7 +765,7 @@ class addressbook_ui extends addressbook_bo
'icon' => 'filemanager/mail_post_to',
'group' => $group,
'onExecute' => 'javaScript:app.addressbook.adb_mail_vcard',
- 'disableClass' => 'contact_organisation',
+ 'enableClass' => 'contact_contact',
'hideOnDisabled' => true,
'hideOnMobile' => true
);
@@ -807,22 +830,99 @@ class addressbook_ui extends addressbook_bo
}
/**
- * Get the name of an organization from an ID for the org_view filter
+ * Get a nice name for the given grouped view ID
*
- * @param string $org
- * @return Array ID => name
+ * @param String $view_id Some kind of indicator for a specific group, either
+ * organisation or duplicate. It looks like key:value pairs seperated by |||.
+ *
+ * @return Array(ID => name), where ID is the $view_id passed in
*/
- private function _get_org_name($org)
+ protected function _get_grouped_name($view_id)
{
- $org_name = array();
- if (strpos($org,'*AND*')!== false) $org = str_replace('*AND*','&',$org);
- foreach(explode('|||',$org) as $part)
+ $group_name = array();
+ if (strpos($view_id,'*AND*')!== false) $view_id = str_replace('*AND*','&',$view_id);
+ foreach(explode('|||',$view_id) as $part)
{
list(,$name) = explode(':',$part,2);
- if ($name) $org_name[] = $name;
+ if ($name) $group_name[] = $name;
}
- $name = implode(': ',$org_name);
- return $name ? array($org => $name) : array();
+ $name = implode(': ',$group_name);
+ return $name ? array($view_id => $name) : array();
+ }
+
+ /**
+ * Unset the relevant column filters to clear a grouped view
+ *
+ * @param Array $query
+ */
+ protected function unset_grouped_filters(&$query)
+ {
+ unset($query['col_filter']['org_name']);
+ unset($query['col_filter']['org_unit']);
+ unset($query['col_filter']['adr_one_locality']);
+ foreach(static::$duplicate_fields as $field => $label)
+ {
+ unset($query['col_filter'][$field]);
+ }
+ }
+
+ /**
+ * Adjust the query as needed and get the rows for the grouped views (organisation
+ * or duplicate contacts)
+ *
+ * @param Array $query Nextmatch query
+ * @return array rows found
+ */
+ protected function get_grouped_rows(&$query)
+ {
+ // Query doesn't like empties
+ unset($query['col_filter']['parent_id']);
+
+ if($query['actions'] && $query['actions']['open'])
+ {
+ // Just switched from contact view, update actions
+ $query['actions'] = $this->get_actions($query['col_filter']['tid']);
+ }
+ unset($query['col_filter']['list']); // does not work together
+ $query['no_filter2'] = true; // switch the distribution list selection off
+
+ $query['template'] = $query['grouped_view'] == 'duplicates' ? 'addressbook.index.duplicate_rows' : 'addressbook.index.org_rows';
+
+ if ($query['advanced_search'])
+ {
+ $query['op'] = $query['advanced_search']['operator'];
+ unset($query['advanced_search']['operator']);
+ $query['wildcard'] = $query['advanced_search']['meth_select'];
+ unset($query['advanced_search']['meth_select']);
+ $original_search = $query['search'];
+ $query['search'] = $query['advanced_search'];
+ }
+
+ switch ($query['template'])
+ {
+ case 'addressbook.index.org_rows':
+ if ($query['order'] != 'org_name')
+ {
+ $query['sort'] = 'ASC';
+ $query['order'] = 'org_name';
+ }
+ $query['org_view'] = $query['grouped_view'];
+ $rows = parent::organisations($query);
+ break;
+ case 'addressbook.index.duplicate_rows':
+ $rows = parent::duplicates($query);
+ break;
+ }
+
+ if ($query['advanced_search'])
+ {
+ $query['search'] = $original_search;
+ unset($query['wildcard']);
+ unset($query['op']);
+ }
+ $GLOBALS['egw_info']['flags']['params']['manual'] = array('page' => 'ManualAddressbookIndexOrga');
+
+ return $rows;
}
/**
@@ -886,7 +986,7 @@ window.egw_LAB.wait(function() {
if(!is_array($org)) $org = array($org);
foreach($org as $org_name)
{
- $query['org_view'] = $org_name;
+ $query['grouped_view'] = $org_name;
$checked = array();
$readonlys = null;
$this->get_rows($query,$checked,$readonlys,true); // true = only return the id's
@@ -907,7 +1007,7 @@ window.egw_LAB.wait(function() {
{
$query = Api\Cache::getSession('addressbook', 'index');
$query['num_rows'] = -1; // all
- $query['org_view'] = $org;
+ $query['grouped_view'] = $org;
$query['searchletter'] = '';
$checked = $readonlys = null;
$this->get_rows($query,$checked,$readonlys,true); // true = only return the id's
@@ -1047,26 +1147,8 @@ window.egw_LAB.wait(function() {
}
}
// replace org_name:* id's with all id's of that org
- $org_contacts = array();
- foreach((array)$checked as $n => $id)
- {
- if (substr($id,0,9) == 'org_name:')
- {
- if (count($checked) == 1 && !count($org_contacts) && $action == 'infolog')
- {
- return $this->infolog_org_view($id); // uses the org-name, instead of 'selected contacts'
- }
- unset($checked[$n]);
- $query = Api\Cache::getSession('addressbook', $session_name);
- $query['num_rows'] = -1; // all
- $query['org_view'] = $id;
- unset($query['filter2']);
- $extra = $readonlys = null;
- $this->get_rows($query,$extra,$readonlys,true); // true = only return the id's
- if ($extra[0]) $org_contacts = array_merge($org_contacts,$extra);
- }
- }
- if ($org_contacts) $checked = array_unique($checked ? array_merge($checked,$org_contacts) : $org_contacts);
+ $grouped_contacts = $this->find_grouped_ids($action, $checked, $use_all, $success,$failed,$action_msg,$session_name);
+ if ($grouped_contacts) $checked = array_unique($checked ? array_merge($checked,$grouped_contacts) : $grouped_contacts);
//_debug_array($checked); exit;
if (substr($action,0,8) == 'move_to_')
@@ -1338,6 +1420,59 @@ window.egw_LAB.wait(function() {
return !$failed;
}
+ /**
+ * Find the individual contact IDs for a list of grouped contacts
+ *
+ * Successful lookups are removed from the checked array.
+ *
+ * Used for action on organisation and duplicate views
+ * @param string/int $action 'delete', 'vcard', 'csv' or nummerical account_id to move contacts to that addessbook
+ * @param array $checked contact id's to use if !$use_all
+ * @param boolean $use_all if true use all contacts of the current selection (in the session)
+ * @param int &$success number of succeded actions
+ * @param int &$failed number of failed actions (not enought permissions)
+ * @param string &$action_msg translated verb for the actions, to be used in a message like %1 contacts 'deleted'
+ * @param string/array $session_name 'index' or 'email', or array with session-data depending if we are in the main list or the popup
+ *
+ * @return array List of contact IDs in the provided groups
+ */
+ protected function find_grouped_ids($action,&$checked,$use_all,&$success,&$failed,&$action_msg,$session_name,&$msg)
+ {
+ $grouped_contacts = array();
+ foreach((array)$checked as $n => $id)
+ {
+ if (substr($id,0,9) == 'org_name:' || substr($id, 0,10) == 'duplicate:')
+ {
+ if (count($checked) == 1 && !count($grouped_contacts) && $action == 'infolog')
+ {
+ return $this->infolog_org_view($id); // uses the org-name, instead of 'selected contacts'
+ }
+ unset($checked[$n]);
+ $query = Api\Cache::getSession('addressbook', $session_name);
+ $query['num_rows'] = -1; // all
+ $query['grouped_view'] = $id;
+ unset($query['filter2']);
+ $extra = $readonlys = null;
+ $this->get_rows($query,$extra,$readonlys,true); // true = only return the id's
+
+ // Merge them here, so we only merge the ones that are duplicates,
+ // not merge all selected together
+ if($action == 'merge_duplicates')
+ {
+ $loop_success = $loop_fail = 0;
+ $this->action('merge', $extra, false, $loop_success, $loop_fail, $action_msg,$session_name,$msg);
+ $success += $loop_success;
+ $failed += $loop_fail;
+ }
+ if ($extra[0])
+ {
+ $grouped_contacts = array_merge($grouped_contacts,$extra);
+ }
+ }
+ }
+ return $grouped_contacts;
+ }
+
/**
* Copy a given contact (not storing it!)
*
@@ -1400,30 +1535,21 @@ window.egw_LAB.wait(function() {
{
$old_state = Api\Cache::getSession('addressbook', $what);
}
- if (!isset($this->org_views[(string) $query['org_view']]) || strpos($query['org_view'],':') === false) // we dont have an org view, unset the according col_filters
+ if (!isset($this->grouped_views[(string) $query['grouped_view']]) || strpos($query['grouped_view'],':') === false)
{
- if (isset($query['col_filter']['org_name'])) unset($query['col_filter']['org_name']);
- if (isset($query['col_filter']['adr_one_locality'])) unset($query['col_filter']['adr_one_locality']);
- if (isset($query['col_filter']['org_unit'])) unset($query['col_filter']['org_unit']);
+ // we don't have a grouped view, unset the according col_filters
+ $this->unset_grouped_filters($query);
}
- if (isset($this->org_views[(string) $query['org_view']])) // we have an org view, reset the advanced search
+ if (isset($this->grouped_views[(string) $query['grouped_view']]))
{
- //_debug_array(array('Search'=>$query['search'],
- // 'AdvancedSearch'=>$query['advanced_search']));
- //if (is_array($query['search'])) unset($query['search']);
- //unset($query['advanced_search']);
+ // we have a grouped view, reset the advanced search
if(!$query['search'] && $old_state['advanced_search']) $query['advanced_search'] = $old_state['advanced_search'];
}
elseif(!$query['search'] && array_key_exists('advanced_search',$old_state)) // eg. paging in an advanced search
{
$query['advanced_search'] = $old_state['advanced_search'];
}
- /* this cant work anymore, as Framework::set_onload no longer exists
- if ($do_email && etemplate::$loop)
- { // remove previous addEmail() calls, otherwise they will be run again
- Framework::set_onload(preg_replace('/addEmail\([^)]+\);/','',Framework::set_onload()),true);
- }*/
// Make sure old lettersearch filter doesn't stay - current letter filter will be added later
foreach($query['col_filter'] as $key => $col_filter)
@@ -1441,7 +1567,7 @@ window.egw_LAB.wait(function() {
if (!$id_only)
{
// check if accounts are stored in ldap, which does NOT yet support the org-views
- if ($this->so_accounts && $query['filter'] === '0' && $query['org_view'])
+ if ($this->so_accounts && $query['filter'] === '0' && $query['grouped_view'])
{
if ($old_state['filter'] === '0') // user changed to org_view
{
@@ -1449,21 +1575,21 @@ window.egw_LAB.wait(function() {
}
else // user changed to accounts
{
- $query['org_view'] = ''; // --> change to regular contacts view
+ $query['grouped_view'] = ''; // --> change to regular contacts view
}
}
- if ($query['org_view'] && isset($this->org_views[$old_state['org_view']]) && !isset($this->org_views[$query['org_view']]))
+ if ($query['grouped_view'] && isset($this->grouped_views[$old_state['grouped_view']]) && !isset($this->grouped_views[$query['grouped_view']]))
{
- $query['searchletter'] = ''; // reset lettersearch if viewing the contacts of one organisation
+ $query['searchletter'] = ''; // reset lettersearch if viewing the contacts of one group (org or duplicates)
}
// save the state of the index in the user prefs
$state = serialize(array(
- 'filter' => $query['filter'],
- 'cat_id' => $query['cat_id'],
- 'order' => $query['order'],
- 'sort' => $query['sort'],
- 'col_filter' => array('tid' => $query['col_filter']['tid']),
- 'org_view' => $query['org_view'],
+ 'filter' => $query['filter'],
+ 'cat_id' => $query['cat_id'],
+ 'order' => $query['order'],
+ 'sort' => $query['sort'],
+ 'col_filter' => array('tid' => $query['col_filter']['tid']),
+ 'grouped_view' => $query['grouped_view'],
));
if ($state != $this->prefs[$what.'_state'] && !$query['csv_export'])
{
@@ -1511,44 +1637,11 @@ window.egw_LAB.wait(function() {
// all backends allow now at least to use groups as distribution lists
$query['no_filter2'] = false;
- if (isset($this->org_views[(string) $query['org_view']]) && !$query['col_filter']['parent_id']) // we have an org view
+ // Grouped view
+ if (isset($this->grouped_views[(string) $query['grouped_view']]) && !$query['col_filter']['parent_id'])
{
- // Query doesn't like empties
- unset($query['col_filter']['parent_id']);
-
- if($query['actions'] && $query['actions']['open'])
- {
- // Just switched from contact view, update actions
- $query['actions'] = $this->get_actions($query['col_filter']['tid'], $query['org_view']);
- }
- unset($query['col_filter']['list']); // does not work together
- $query['no_filter2'] = true; // switch the distribution list selection off
-
- $query['template'] = 'addressbook.index.org_rows';
-
- if ($query['order'] != 'org_name')
- {
- $query['sort'] = 'ASC';
- $query['order'] = 'org_name';
- }
- if ($query['advanced_search'])
- {
- $query['op'] = $query['advanced_search']['operator'];
- unset($query['advanced_search']['operator']);
- $query['wildcard'] = $query['advanced_search']['meth_select'];
- unset($query['advanced_search']['meth_select']);
- $original_search = $query['search'];
- $query['search'] = $query['advanced_search'];
- }
-
- $rows = parent::organisations($query);
- if ($query['advanced_search'])
- {
- $query['search'] = $original_search;
- unset($query['wildcard']);
- unset($query['op']);
- }
- $GLOBALS['egw_info']['flags']['params']['manual'] = array('page' => 'ManualAddressbookIndexOrga');
+ $query['grouped_view_label'] = '';
+ $rows = $this->get_grouped_rows($query);
}
else // contacts view
{
@@ -1562,19 +1655,21 @@ window.egw_LAB.wait(function() {
}
if($query['col_filter']['parent_id'])
{
- $query['org_view'] = $query['col_filter']['parent_id'];
- $query['template'] = 'addressbook.index.org_rows';
+ $query['grouped_view'] = $query['col_filter']['parent_id'];
+ $query['template'] = strpos($query['grouped_view'], 'duplicate') === 0 ?
+ 'addressbook.index.duplicate_rows' : 'addressbook.index.org_rows';
}
// Query doesn't like parent_id
unset($query['col_filter']['parent_id']);
- if ($query['org_view']) // view the contacts of one organisation only
+ if ($query['grouped_view']) // view the contacts of one organisation only
{
- if (strpos($query['org_view'],'*AND*') !== false) $query['org_view'] = str_replace('*AND*','&',$query['org_view']);
- foreach(explode('|||',$query['org_view']) as $part)
+ if (strpos($query['grouped_view'],'*AND*') !== false) $query['grouped_view'] = str_replace('*AND*','&',$query['grouped_view']);
+ $fields = explode(',',$GLOBALS['egw_info']['user']['preferences']['addressbook']['duplicate_fields']);
+ foreach(explode('|||',$query['grouped_view']) as $part)
{
list($name,$value) = explode(':',$part,2);
// do NOT set invalid column, as this gives an SQL error ("AND AND" in sql)
- if (in_array($name, array('org_name','org_unit','adr_one_location')))
+ if (static::$duplicate_fields[$name] && in_array($name, $fields) && $value)
{
$query['col_filter'][$name] = $value;
}
@@ -1582,8 +1677,8 @@ window.egw_LAB.wait(function() {
}
else if($query['actions'] && !$query['actions']['edit'])
{
- // Just switched from org view, update actions
- $query['actions'] = $this->get_actions($query['col_filter']['tid'], $query['org_view']);
+ // Just switched from grouped view, update actions
+ $query['actions'] = $this->get_actions($query['col_filter']['tid']);
}
// translate the select order to the really used over all 3 columns
$sort = $query['sort'];
@@ -1712,17 +1807,17 @@ window.egw_LAB.wait(function() {
list($row['line1'],$row['line2']) = explode(': ',$row['n_fileas']);
break;
}
- if (isset($this->org_views[(string) $query['org_view']]))
+ if (isset($this->grouped_views[(string) $query['grouped_view']]))
{
$row['type'] = 'home';
- $row['type_label'] = lang('Organisation');
+ $row['type_label'] = $query['grouped_view'] == 'duplicate' ? lang('Duplicates') : lang('Organisation');
if ($query['filter'] && !($this->grants[(int)$query['filter']] & Acl::DELETE))
{
$row['class'] .= 'rowNoDelete ';
}
$row['class'] .= 'rowNoEdit '; // no edit in OrgView
- $row['class'] .= 'contact_organisation ';
+ $row['class'] .= $query['grouped_view'] == 'duplicates' ? 'contact_duplicate' : 'contact_organisation ';
}
else
{
@@ -1802,7 +1897,7 @@ window.egw_LAB.wait(function() {
// full app-header with all search criteria specially for the print
$header = array();
- if ($query['filter'] !== '' && !isset($this->org_views[$query['org_view']]))
+ if ($query['filter'] !== '' && !isset($this->grouped_views[$query['grouped_view']]))
{
$header[] = ($query['filter'] == '0' ? lang('accounts') :
($GLOBALS['egw']->accounts->get_type($query['filter']) == 'g' ?
@@ -1810,14 +1905,14 @@ window.egw_LAB.wait(function() {
Api\Accounts::username((int)$query['filter']).
(substr($query['filter'],-1) == 'p' ? ' ('.lang('private').')' : '')));
}
- if ($query['org_view'])
+ if ($query['grouped_view'])
{
- $header[] = $query['org_view_label'];
+ $header[] = $query['grouped_view_label'];
// Make sure option is there
- if(!array_key_exists($query['org_view'], $this->org_views))
+ if(!array_key_exists($query['grouped_view'], $this->grouped_views))
{
- $this->org_views += $this->_get_org_name($query['org_view']);
- $rows['sel_options']['org_view'] = $this->org_views;
+ $this->grouped_views += $this->_get_grouped_name($query['grouped_view']);
+ $rows['sel_options']['grouped_view'] = $this->grouped_views;
}
}
if($query['advanced_search'])
@@ -2103,9 +2198,9 @@ window.egw_LAB.wait(function() {
if ($org[0] == '"') $org = substr($org, 1, -1);
$content = $this->read_org($org);
}
- elseif ($state['org_view'] && !isset($this->org_views[$state['org_view']]))
+ elseif ($state['grouped_view'] && !isset($this->grouped_views[$state['grouped_view']]))
{
- $content = $this->read_org($state['org_view']);
+ $content = $this->read_org($state['grouped_view']);
}
else
{
@@ -2491,12 +2586,14 @@ window.egw_LAB.wait(function() {
public function ajax_check_values($values, $name, $own_id=0)
{
$matches = null;
+ $fields = explode(',',$GLOBALS['egw_info']['user']['preferences']['addressbook']['duplicate_fields']);
+
if (preg_match('/^exec\[([^\]]+)\]$/', $name, $matches)) $name = $matches[1]; // remove exec[ ]
$ret = array('doublicates' => array(), 'msg' => null);
// if email changed, check for doublicates
- if (in_array($name, array('email', 'email_home')))
+ if (in_array($name, array('email', 'email_home')) && in_array($name, $fields))
{
if (preg_match(Etemplate\Widget\Url::EMAIL_PREG, $values[$name])) // only search for real email addresses, to not return to many contacts
{
@@ -2513,12 +2610,19 @@ window.egw_LAB.wait(function() {
// Full options for et2
$ret['fileas_sel_options'] = $this->fileas_options($values);
- // if name, firstname or org changed and at least 2 are specified, check for doublicates
- if (in_array($name, array('n_given', 'n_family', 'org_name')) &&
- !empty($values['n_given'])+!empty($values['n_family'])+!empty($values['org_name']) >= 2)
+ // if name, firstname or org changed and enough are specified, check for doublicates
+ $specified_count = 0;
+ foreach($fields as $field)
+ {
+ if($values[$field])
+ {
+ $specified_count++;
+ }
+ }
+ if (in_array($name,$fields) && $specified_count >= 2)
{
$filter = array();
- foreach(array('email', 'n_given', 'n_family', 'org_name') as $n) // use email too, to exclude obvious false positives
+ foreach($fields as $n) // use email too, to exclude obvious false positives
{
if (!empty($values[$n])) $filter[$n] = $values[$n];
}
diff --git a/addressbook/templates/default/index.duplicate_rows.xet b/addressbook/templates/default/index.duplicate_rows.xet
new file mode 100644
index 0000000000..7f5708f618
--- /dev/null
+++ b/addressbook/templates/default/index.duplicate_rows.xet
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addressbook/templates/default/index.xet b/addressbook/templates/default/index.xet
index 24874a5852..7296aa6e39 100644
--- a/addressbook/templates/default/index.xet
+++ b/addressbook/templates/default/index.xet
@@ -174,7 +174,7 @@
-
+
diff --git a/api/src/Contacts/Sql.php b/api/src/Contacts/Sql.php
index c09837a76c..5899cffdd0 100644
--- a/api/src/Contacts/Sql.php
+++ b/api/src/Contacts/Sql.php
@@ -207,6 +207,7 @@ class Sql extends Api\Storage
$filter['org_name'][$row['org_name']] = $row['org_name']; // use as key too to have every org only once
}
$org_key = $row['org_name'].($by ? '|||'.($row[$by] || $row[$by.'_count']==1 ? $row[$by] : '|||') : '');
+ $row['group_count'] = $row['org_count'];
$orgs[$org_key] = $row;
}
unset($rows);
@@ -242,6 +243,141 @@ class Sql extends Api\Storage
return array_values($orgs);
}
+
+ /**
+ * Query for duplicate contacts according to given parameters
+ *
+ * We join egw_addressbook to itself, and count how many fields match. If
+ * enough of the fields we care about match, we count those two records as
+ * duplicates.
+ *
+ * @var array $param
+ * @var string $param[grouped_view] 'duplicate', 'duplicate,adr_one_location', 'duplicate,org_name' how to group
+ * @var int $param[owner] addressbook to search
+ * @var string $param[search] search pattern for org_name
+ * @var string $param[searchletter] letter the name need to start with
+ * @var array $param[col_filter] filter
+ * @var string $param[search] or'ed search pattern
+ * @var array $param[advanced_search] indicator that advanced search is active
+ * @var string $param[op] (operator like AND or OR; will be passed when advanced search is active)
+ * @var string $param[wildcard] (wildcard like % or empty or not set (for no wildcard); will be passed when advanced search is active)
+ * @var int $param[start]
+ * @var int $param[num_rows]
+ * @var string $param[sort] ASC or DESC
+ * @return array or arrays with keys org_name,count and evtl. adr_one_location or org_unit
+ */
+ function duplicates($param)
+ {
+ $join = 'JOIN ' . $this->table_name . ' AS a2 ON ';
+ $filter = array(
+ $this->table_name.'.contact_tid != "D"'
+ );
+ $op = 'OR';
+ if (isset($param['op']) && !empty($param['op'])) $op = $param['op'];
+ $advanced_search = false;
+ if (isset($param['advanced_search']) && !empty($param['advanced_search'])) $advanced_search = true;
+ $wildcard ='%';
+ if ($advanced_search || (isset($param['wildcard']) && !empty($param['wildcard']))) $wildcard = ($param['wildcard']?$param['wildcard']:'');
+
+ // fix cat_id filter to search in comma-separated multiple cats and return subcats
+ if ($param['cat_id'])
+ {
+ $cat_filter = $this->_cat_filter($filter['cat_id']);
+ $filter[] = str_replace('cat_id', $this->table_name . '.cat_id', $cat_filter);
+ $join .= str_replace('cat_id', 'a2.cat_id', $cat_filter) . ' AND ';
+ unset($filter['cat_id']);
+ }
+ // add filter for read ACL in sql, if user is NOT the owner of the addressbook
+ if ($param['owner'] && $param['owner'] == $GLOBALS['egw_info']['user']['account_id'])
+ {
+ $filter['owner'] = $param['owner'];
+ $join .= 'a2.owner = ' . $this->db->quote($filter['owner']) . ' AND ';
+ }
+ else
+ {
+ // we have no private grants in addressbook at the moment, they have then to be added here too
+ if ($param['owner'])
+ {
+ if (!$this->grants[(int) $filter['owner']]) return false; // we have no access to that addressbook
+
+ $filter['owner'] = $param['owner'];
+ $filter['private'] = 0;
+ $join .= 'a2.owner = ' . $this->db->quote($filter['owner']) . ' AND ';
+ $join .= 'a2.private = ' . $this->db->quote($filter['private']) . ' AND ';
+ }
+ else // search all addressbooks, incl. accounts
+ {
+ if ($this->account_repository != 'sql' && $this->contact_repository != 'sql-ldap')
+ {
+ $filter[] = $this->table_name.'.contact_owner != 0'; // in case there have been accounts in sql previously
+ }
+ $filter[] = $access = "(".$this->table_name.".contact_owner=".(int)$GLOBALS['egw_info']['user']['account_id'].
+ " OR {$this->table_name}.contact_private=0 AND ".$this->table_name.".contact_owner IN (".
+ implode(',',array_keys($this->grants))."))";
+ $join .= str_replace($this->table_name, 'a2', $access) . ' AND ';
+ }
+ }
+ if ($param['searchletter'])
+ {
+ $filter[] = $this->table_name.'.n_fn '.$this->db->capabilities[Api\Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '.$this->db->quote($param['searchletter'].'%');
+ }
+ $sort = $param['sort'] == 'DESC' ? 'DESC' : 'ASC';
+ $group = $GLOBALS['egw_info']['user']['preferences']['addressbook']['duplicate_fields'] ?
+ explode(',',$GLOBALS['egw_info']['user']['preferences']['addressbook']['duplicate_fields']):
+ array('n_family', 'n_given', 'org_name', 'contact_email');
+ $match_count = $GLOBALS['egw_info']['user']['preferences']['addressbook']['duplicate_threshold'] ?
+ $GLOBALS['egw_info']['user']['preferences']['addressbook']['duplicate_threshold'] : 3;
+
+ $extra = Array();
+ $order = in_array($param['order'], $group) ? $param['order'] : $group[0];
+ $join .= $this->table_name .'.contact_id != a2.contact_id AND a2.contact_tid != "D" AND (';
+ $join_fields = Array();
+ foreach($group as &$field)
+ {
+ $extra[] = "IF({$this->table_name}.$field = a2.$field, 1, 0)";
+ $join_fields[] = $this->table_name . ".$field = a2.$field";
+ $field = $this->table_name . ".$field AS $field";
+ }
+ $extra = Array(
+ 'a2.contact_id AS matched',
+ implode('+', $extra) . ' AS match_count'
+ );
+ $join .= $this->db->column_data_implode(' OR ',$join_fields) . ')';
+
+ $append = " HAVING match_count >= $match_count ORDER BY {$this->table_name}.{$order} $sort, $this->table_name.contact_id";
+ $group[] = $this->table_name.'.contact_id AS contact_id';
+
+ $rows = parent::search($param['search'],$group,
+ $append,$extra,$wildcard,false,$op/*'OR'*/,
+ array($param['start'],$param['num_rows']),$filter, $join);
+
+ // Go through rows and only return one for each pair/triplet/etc. of matches
+ $dupes = array();
+ foreach($rows as $key => &$row)
+ {
+ if(array_key_exists($row['contact_id'], $dupes))
+ {
+ $kept_row =& $rows[$dupes[$row['contact_id']]];
+ $kept_row['group_count']++;
+
+ // Clear not matching fields, or we won't be able to find children
+ foreach($kept_row as $sub_key => $sub_value)
+ {
+ if(in_array($sub_key, array('contact_id','group_count'))) continue;
+ if($row[$sub_key] != $sub_value)
+ {
+ unset($kept_row[$sub_key]);
+ }
+ }
+ unset($rows[$key]);
+ $this->total--;
+ }
+ $dupes[$row['matched']] = $key;
+ $row['group_count'] = 1;
+ }
+ return array_values($rows);
+ }
+
/**
* searches db for rows matching searchcriteria
*
diff --git a/api/src/Contacts/Storage.php b/api/src/Contacts/Storage.php
index f83de18758..f7ba0306c2 100755
--- a/api/src/Contacts/Storage.php
+++ b/api/src/Contacts/Storage.php
@@ -159,6 +159,24 @@ class Storage
*/
var $content_types = array();
+ /**
+ * These fields are options for checking for duplicate contacts
+ *
+ * @var array
+ */
+ public static $duplicate_fields = array(
+ 'n_given' => 'first name',
+ 'n_middle' => 'middle name',
+ 'n_family' => 'last name',
+ 'contact_bday' => 'birthday',
+ 'org_name' => 'Organisation',
+ 'org_unit' => 'Department',
+ 'adr_one_locality' => 'Location',
+ 'contact_title' => 'title',
+ 'contact_email' => 'business email',
+ 'contact_email_home'=> 'email (private)',
+ );
+
/**
* Special content type to indicate a deleted addressbook
*
@@ -796,6 +814,80 @@ class Storage
return $rows;
}
+ /**
+ * Find contacts that appear to be duplicates
+ *
+ * @param Array $param
+ * @param string $param[org_view] 'org_name', 'org_name,adr_one_location', 'org_name,org_unit' how to group
+ * @param int $param[owner] addressbook to search
+ * @param string $param[search] search pattern for org_name
+ * @param string $param[searchletter] letter the org_name need to start with
+ * @param int $param[start]
+ * @param int $param[num_rows]
+ * @param string $param[sort] ASC or DESC
+ *
+ * @return array of arrays
+ */
+ public function duplicates($param)
+ {
+ if (!method_exists($this->somain,'duplicates'))
+ {
+ $this->total = 0;
+ return false;
+ }
+ if ($param['search'] && !is_array($param['search']))
+ {
+ $search = $param['search'];
+ $param['search'] = array();
+ if($this->somain instanceof Sql)
+ {
+ // Keep the string, let the parent deal with it
+ $param['search'] = $search;
+ }
+ else
+ {
+ foreach($this->columns_to_search as $col)
+ {
+ // we don't search the customfields
+ if ($col != 'contact_value') $param['search'][$col] = $search;
+ }
+ }
+ }
+ if (is_array($param['search']) && count($param['search']))
+ {
+ $param['search'] = $this->data2db($param['search']);
+ }
+ if(!array_key_exists('tid', $param['col_filter']) || $param['col_filter']['tid'] === '')
+ {
+ $param['col_filter'][] = 'contact_tid != \'' . self::DELETED_TYPE . '\'';
+ }
+ elseif(is_null($param['col_filter']['tid']))
+ {
+ // return all entries including deleted
+ unset($param['col_filter']['tid']);
+ }
+
+
+ $rows = $this->somain->duplicates($param);
+ $this->total = $this->somain->total;
+
+ if (!$rows) return array();
+
+ foreach($rows as $n => $row)
+ {
+ $rows[$n]['id'] = 'duplicate:';
+ foreach(static::$duplicate_fields as $by => $by_label)
+ {
+ if (strpos($row[$by],'&')!==false) $row[$by] = str_replace('&','*AND*',$row[$by]);
+ if($row[$by])
+ {
+ $rows[$n]['id'] .= '|||'.$by.':'.$row[$by];
+ }
+ }
+ }
+ return $rows;
+ }
+
/**
* gets all contact fields from database
*