diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index 7857140084..c00429878d 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -95,35 +95,127 @@ public function get_ids_of_non_dismissed_duplicates_endpoint( WP_REST_Request $r } + /** + * Extract field values for duplicate search based on field type + * + * @param array $post The post data array + * @param string $field_key The field key to extract + * @param array $field_settings The field settings array + * @param string $exact_template The exact match template (e.g., '^' for exact, '' for fuzzy) + * @return array|null Array of search values or null if no values found + */ + private static function extract_field_values_for_duplicate_search( $post, $field_key, $field_settings, $exact_template ){ + if ( !isset( $field_settings['type'] ) || empty( $field_settings['type'] ) ){ + return null; + } + + $field_type = $field_settings['type']; + $field_value = $post[$field_key] ?? null; + + if ( empty( $field_value ) ){ + return null; + } + + $search_values = []; + + switch ( $field_type ) { + case 'text': + case 'textarea': + case 'number': + // Direct string/number value + $search_values[] = $exact_template . $field_value; + break; + + case 'communication_channel': + // Array of objects with 'value' key + if ( is_array( $field_value ) ){ + foreach ( $field_value as $value ){ + if ( !empty( $value['value'] ) ){ + $search_values[] = $exact_template . $value['value']; + } + } + } + break; + + case 'tags': + // Array of tag values — no exact prefix; these are discrete keys, always matched with = + if ( is_array( $field_value ) ){ + foreach ( $field_value as $tag ){ + $tag_value = is_array( $tag ) ? ( $tag['value'] ?? $tag['label'] ?? '' ) : $tag; + if ( !empty( $tag_value ) ){ + $search_values[] = $tag_value; + } + } + } + break; + + case 'multi_select': + // Array of selected keys — no exact prefix; these are discrete keys, always matched with = + if ( is_array( $field_value ) ){ + foreach ( $field_value as $selected ){ + $selected_value = is_array( $selected ) ? ( $selected['key'] ?? $selected['value'] ?? '' ) : $selected; + if ( !empty( $selected_value ) ){ + $search_values[] = $selected_value; + } + } + } + break; + + default: + // For other types, try to extract as string + if ( is_scalar( $field_value ) ){ + $search_values[] = $exact_template . $field_value; + } else if ( is_array( $field_value ) && isset( $field_value['value'] ) ){ + $search_values[] = $exact_template . $field_value['value']; + } + break; + } + + return !empty( $search_values ) ? $search_values : null; + } + private static function query_for_duplicate_searches( $post_type, $post_id, $exact = true ){ $post = DT_Posts::get_post( $post_type, $post_id ); $fields = DT_Posts::get_post_field_settings( $post_type ); $search_query = []; $exact_template = $exact ? '^' : ''; $fields_with_values = []; - foreach ( $post as $field_key => $field_value ){ - if ( ! isset( $fields[$field_key]['type'] ) || empty( $fields[$field_key]['type'] ) ){ + + // Get configured duplicate fields, or use defaults if not configured + $site_options = dt_get_option( 'dt_site_options' ); + $duplicates_config = $site_options['duplicates'] ?? []; + + // Check if valid configuration exists for this post type + // Use saved config if it exists and is not empty, otherwise use defaults + if ( isset( $duplicates_config[$post_type] ) && !empty( $duplicates_config[$post_type] ) ) { + // Use saved configuration + $configured_fields = $duplicates_config[$post_type]; + } else { + // No valid configuration exists - use defaults + $configured_fields = dt_get_duplicate_fields_defaults( $post_type ); + } + + // Process each configured field + foreach ( $configured_fields as $field_key ){ + // Skip if field doesn't exist in post or field settings + if ( !isset( $post[$field_key] ) || !isset( $fields[$field_key] ) ){ continue; } - if ( $fields[$field_key]['type'] === 'communication_channel' ){ - if ( !empty( $field_value ) ){ - $channel_queries = []; - foreach ( $field_value as $value ){ - if ( !empty( $value['value'] ) ){ - $channel_queries[] = $exact_template . $value['value']; - } - } - if ( !empty( $channel_queries ) ){ - $fields_with_values[] = $field_key; - $search_query[$field_key] = []; - $search_query[$field_key] = $channel_queries; - } - } - } else if ( $field_key === 'name' && !empty( $field_value ) ){ + + // Extract values based on field type + $search_values = self::extract_field_values_for_duplicate_search( + $post, + $field_key, + $fields[ $field_key ], + $exact_template + ); + + if ( !empty( $search_values ) ){ $fields_with_values[] = $field_key; - $search_query[$field_key] = [ $exact_template . $field_value ]; + $search_query[ $field_key ] = $search_values; } } + return [ 'query' => $search_query, 'fields' => $fields_with_values, @@ -143,7 +235,7 @@ public static function ids_of_non_dismissed_duplicates( $post_type, $post_id, $e } global $wpdb; $search_query = self::query_for_duplicate_searches( $post_type, $post_id, $exact ); - $res = DT_Posts::search_viewable_post( 'contacts', [ $search_query['query'] ] ); + $res = DT_Posts::search_viewable_post( $post_type, [ $search_query['query'] ] ); if ( is_wp_error( $res ) ){ return $res; } @@ -231,19 +323,40 @@ public static function get_all_duplicates_on_post( $post_type, $post_id ){ $match_on = []; $points = 0; foreach ( $fuzzy_query['fields'] as $field_key ){ - if ( $field_settings[$field_key]['type'] === 'text' ){ - if ( $post[$field_key] === $possible_duplicate[$field_key] ){ - $match_on[] = [ 'field' => $field_key, 'value' => $post[$field_key] ]; + if ( !isset( $field_settings[$field_key]['type'] ) ){ + continue; + } + $field_type = $field_settings[$field_key]['type']; + $post_val = $post[$field_key] ?? null; + $dup_val = $possible_duplicate[$field_key] ?? null; + if ( $post_val === null || $post_val === '' || $dup_val === null ){ + continue; + } + + // text, textarea, number: scalar comparison + if ( in_array( $field_type, [ 'text', 'textarea', 'number' ], true ) ){ + $post_str = is_scalar( $post_val ) ? (string) $post_val : ''; + $dup_str = is_scalar( $dup_val ) ? (string) $dup_val : ''; + if ( $post_str === $dup_str ){ + $match_on[] = [ 'field' => $field_key, 'value' => $post_str ]; $points += 4; - } else if ( stripos( $post[$field_key], $possible_duplicate[$field_key] ) !== false || stripos( $possible_duplicate[$field_key], $post[$field_key] ) !== false ){ - $match_on[] = [ 'field' => $field_key, 'value' => $post[$field_key] ]; + } else if ( $post_str !== '' && $dup_str !== '' && ( stripos( $post_str, $dup_str ) !== false || stripos( $dup_str, $post_str ) !== false ) ){ + $match_on[] = [ 'field' => $field_key, 'value' => $post_str ]; $points++; } } - if ( $field_settings[$field_key]['type'] === 'communication_channel' ){ - foreach ( $post[$field_key] as $value ){ - foreach ( $possible_duplicate[$field_key] as $dup_value ){ - $points++; + if ( $field_type === 'communication_channel' ){ + if ( !is_array( $post_val ) || !is_array( $dup_val ) ){ + continue; + } + foreach ( $post_val as $value ){ + if ( empty( $value['value'] ) ){ + continue; + } + foreach ( $dup_val as $dup_value ){ + if ( empty( $dup_value['value'] ) ){ + continue; + } if ( $value['value'] === $dup_value['value'] ){ $match_on[] = [ 'field' => $field_key, 'value' => $dup_value['value'] ]; $points += 4; @@ -254,6 +367,38 @@ public static function get_all_duplicates_on_post( $post_type, $post_id ){ } } } + // tags, multi_select: array comparison (value or key) + if ( in_array( $field_type, [ 'tags', 'multi_select' ], true ) ){ + $post_arr = is_array( $post_val ) ? $post_val : []; + $dup_arr = is_array( $dup_val ) ? $dup_val : []; + $post_values = []; + foreach ( $post_arr as $v ){ + $post_values[] = is_array( $v ) ? ( $v['value'] ?? $v['key'] ?? $v['label'] ?? '' ) : (string) $v; + } + $post_values = array_filter( array_unique( $post_values ) ); + $dup_values = []; + foreach ( $dup_arr as $v ){ + $dup_values[] = is_array( $v ) ? ( $v['value'] ?? $v['key'] ?? $v['label'] ?? '' ) : (string) $v; + } + $dup_values = array_filter( array_unique( $dup_values ) ); + foreach ( $post_values as $pv ){ + if ( $pv === '' ){ + continue; + } + foreach ( $dup_values as $dv ){ + if ( $dv === '' ){ + continue; + } + if ( $pv === $dv ){ + $match_on[] = [ 'field' => $field_key, 'value' => $pv ]; + $points += 4; + } else if ( stripos( $pv, $dv ) !== false || stripos( $dv, $pv ) !== false ){ + $match_on[] = [ 'field' => $field_key, 'value' => $pv ]; + $points++; + } + } + } + } } if ( !isset( $ordered[$possible_duplicate['ID']] ) ) { $ordered[$possible_duplicate['ID']] = [ @@ -706,71 +851,108 @@ public static function get_access_duplicates( WP_REST_Request $request ){ } /** - * Search for potential duplicates on a post + * Search for potential duplicates on a post (raw SQL path used by View Duplicates bulk page). + * Uses the same configurable duplicate fields as query_for_duplicate_searches(). * - * @param $post_type - * @param int $post_id the post to look for duplicates on - * @param bool $exact search only for exact matches - * @return array|object|null, the array of matches + * @param string $post_type Post type. + * @param int $post_id The post to look for duplicates on. + * @param bool $exact Search only for exact matches. + * @return array Array of matches with ID, field, value. */ private static function query_for_duplicate_searches_v2( $post_type, $post_id, bool $exact = true ){ $post = DT_Posts::get_post( $post_type, $post_id ); $fields = DT_Posts::get_post_field_settings( $post_type ); - $search_query = []; $exact_template = $exact ? '^' : ''; - $fields_with_values = []; global $wpdb; $all_sql = ''; - foreach ( $post as $field_key => $field_value ){ - if ( ! isset( $fields[$field_key]['type'] ) || empty( $fields[$field_key]['type'] ) ){ + + // Use same configured duplicate fields as query_for_duplicate_searches() + $site_options = dt_get_option( 'dt_site_options' ); + $duplicates_config = $site_options['duplicates'] ?? []; + if ( isset( $duplicates_config[ $post_type ] ) && !empty( $duplicates_config[ $post_type ] ) ) { + $configured_fields = $duplicates_config[ $post_type ]; + } else { + $configured_fields = dt_get_duplicate_fields_defaults( $post_type ); + } + + foreach ( $configured_fields as $field_key ){ + if ( !isset( $fields[ $field_key ]['type'] ) || !isset( $post[ $field_key ] ) ){ continue; } - $table_key = esc_sql( 'field_' . $field_key ); - if ( $fields[$field_key]['type'] === 'communication_channel' ){ - if ( !empty( $field_value ) ){ - $sql_joins = ''; - $where_sql = ''; - $sql_joins .= " LEFT JOIN $wpdb->postmeta as $table_key ON ( $table_key.post_id = p.ID AND $table_key.meta_key LIKE '" . esc_sql( $field_key ) . "%' AND $table_key.meta_key NOT LIKE '%_details' )"; - $sql_joins .= " INNER JOIN $wpdb->postmeta as type ON ( type.post_id = p.ID AND type.meta_key = 'type' AND type.meta_value = 'access' )"; - $channel_queries = []; - foreach ( $field_value as $value ){ - if ( !empty( $value['value'] ) ){ - $where_sql .= ( empty( $where_sql ) ? '' : ' OR ' ) . " $table_key.meta_value = '" . esc_sql( $value['value'] ) . "'"; - $channel_queries[] = $exact_template . $value['value']; - } + $search_values = self::extract_field_values_for_duplicate_search( + $post, + $field_key, + $fields[ $field_key ], + $exact_template + ); + if ( empty( $search_values ) ){ + continue; + } + + $field_type = $fields[ $field_key ]['type']; + $table_key = esc_sql( 'field_' . str_replace( '-', '_', $field_key ) ); + + if ( $field_key === 'name' ){ + foreach ( $search_values as $search_val ){ + $val = ( strpos( $search_val, '^' ) === 0 ) ? substr( $search_val, 1 ) : $search_val; + $op = ( strpos( $search_val, '^' ) === 0 ) ? '=' : 'LIKE'; + if ( $op === 'LIKE' ){ + $esc_val = '%' . esc_sql( $wpdb->esc_like( $val ) ) . '%'; + } else { + $esc_val = esc_sql( $val ); } - if ( !empty( $channel_queries ) ){ - if ( !empty( $all_sql ) ){ - $all_sql .= ' UNION '; - } - $all_sql .= "SELECT p.ID, p.post_title, '" . esc_sql( $field_key ) . "' as field, $table_key.meta_value as value - FROM $wpdb->posts p - $sql_joins - WHERE - ( $where_sql ) - AND p.ID != " . esc_sql( $post_id ) . ' - '; + if ( !empty( $all_sql ) ){ + $all_sql .= ' UNION '; } + $all_sql .= 'SELECT p.ID, p.post_title, \'post_title\' as field, p.post_title as value + FROM ' . $wpdb->posts . ' p + JOIN ' . $wpdb->postmeta . ' pm ON ( p.ID = pm.post_id AND pm.meta_key = \'type\' AND pm.meta_value = \'access\' ) + WHERE p.post_type = \'' . esc_sql( $post_type ) . '\' AND p.post_title ' . $op . ' \'' . $esc_val . '\' + AND p.ID != ' . (int) $post_id; } - } else if ( $field_key === 'name' && !empty( $field_value ) ){ - if ( !empty( $all_sql ) ){ - $all_sql .= ' UNION '; + } else if ( $field_type === 'communication_channel' ){ + $where_parts = []; + foreach ( $search_values as $search_val ){ + $val = ( strpos( $search_val, '^' ) === 0 ) ? substr( $search_val, 1 ) : $search_val; + $where_parts[] = " $table_key.meta_value = '" . esc_sql( $val ) . "'"; + } + if ( !empty( $where_parts ) ){ + if ( !empty( $all_sql ) ){ + $all_sql .= ' UNION '; + } + $all_sql .= 'SELECT p.ID, p.post_title, \'' . esc_sql( $field_key ) . '\' as field, ' . $table_key . '.meta_value as value + FROM ' . $wpdb->posts . ' p + LEFT JOIN ' . $wpdb->postmeta . ' as ' . $table_key . ' ON ( ' . $table_key . '.post_id = p.ID AND ' . $table_key . '.meta_key LIKE \'' . esc_sql( $field_key ) . '%\' AND ' . $table_key . '.meta_key NOT LIKE \'%_details\' ) + INNER JOIN ' . $wpdb->postmeta . ' as type ON ( type.post_id = p.ID AND type.meta_key = \'type\' AND type.meta_value = \'access\' ) + WHERE p.post_type = \'' . esc_sql( $post_type ) . '\' AND ( ' . implode( ' OR ', $where_parts ) . ' ) + AND p.ID != ' . (int) $post_id; + } + } else { + // text, textarea, number, tags, multi_select: postmeta with meta_key = field_key + foreach ( $search_values as $search_val ){ + $val = ( strpos( $search_val, '^' ) === 0 ) ? substr( $search_val, 1 ) : $search_val; + $op = ( strpos( $search_val, '^' ) === 0 ) ? '=' : 'LIKE'; + if ( $op === 'LIKE' ){ + $esc_val = '%' . esc_sql( $wpdb->esc_like( $val ) ) . '%'; + } else { + $esc_val = esc_sql( $val ); + } + if ( !empty( $all_sql ) ){ + $all_sql .= ' UNION '; + } + $all_sql .= 'SELECT p.ID, p.post_title, \'' . esc_sql( $field_key ) . '\' as field, pm.meta_value as value + FROM ' . $wpdb->posts . ' p + INNER JOIN ' . $wpdb->postmeta . ' pm ON ( p.ID = pm.post_id AND pm.meta_key = \'' . esc_sql( $field_key ) . '\' AND pm.meta_value ' . $op . ' \'' . $esc_val . '\' ) + INNER JOIN ' . $wpdb->postmeta . ' type ON ( p.ID = type.post_id AND type.meta_key = \'type\' AND type.meta_value = \'access\' ) + WHERE p.post_type = \'' . esc_sql( $post_type ) . '\' AND p.ID != ' . (int) $post_id; } - $all_sql .= " - SELECT - p.ID, p.post_title, 'post_title' as field, p.post_title as value - FROM $wpdb->posts p - JOIN $wpdb->postmeta pm ON ( p.ID = pm.post_id AND pm.meta_key = 'type' AND pm.meta_value = 'access' ) - WHERE p.post_title = '" . esc_sql( $field_value ) . "' - AND p.post_type = 'contacts' - AND p.ID != " . esc_sql( $post_id ) . ' - '; - - $fields_with_values[] = $field_key; - $search_query[$field_key] = [ $exact_template . $field_value ]; } } - $contacts = $wpdb->get_results( $all_sql, ARRAY_A ); // @phpcs:ignore - return $contacts; + + if ( $all_sql === '' ){ + return []; + } + $contacts = $wpdb->get_results( $all_sql, ARRAY_A ); // phpcs:ignore + return is_array( $contacts ) ? $contacts : []; } } diff --git a/dt-core/admin/admin-enqueue-scripts.php b/dt-core/admin/admin-enqueue-scripts.php index c942ba7a18..fd99213d4a 100644 --- a/dt-core/admin/admin-enqueue-scripts.php +++ b/dt-core/admin/admin-enqueue-scripts.php @@ -73,6 +73,9 @@ function dt_options_scripts() { if ( isset( $_GET['page'] ) && ( in_array( $_GET['page'], $allowed_pages, true ) ) ) { + // Normalize active tab so page=dt_options without tab param works (defaults to general) + $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general'; + wp_register_script( 'jquery-ui-js', 'https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js', [ 'jquery' ], '1.12.1', true ); wp_enqueue_script( 'jquery-ui-js' ); @@ -90,7 +93,11 @@ function dt_options_scripts() { dt_theme_enqueue_style( 'material-font-icons-local', 'dt-core/dependencies/mdi/css/materialdesignicons.min.css', array() ); wp_enqueue_style( 'material-font-icons', 'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css' ); - if ( isset( $_GET['tab'] ) && ( ( $_GET['tab'] === 'people-groups' ) || ( $_GET['tab'] === 'general' ) ) ) { + // Enqueue web components for dt-multi-select and other components (available on entire dt_options page) + dt_theme_enqueue_script( 'web-components', 'dt-assets/build/components/index.js', array(), false ); + dt_theme_enqueue_style( 'web-components-css', 'dt-assets/build/css/light.min.css', array() ); + + if ( ( $active_tab === 'people-groups' ) || ( $active_tab === 'general' ) ) { wp_enqueue_script( 'dt_peoplegroups_scripts', get_template_directory_uri() . '/dt-people-groups/people-groups.js', [ 'jquery', 'jquery-ui-core', @@ -98,6 +105,35 @@ function dt_options_scripts() { wp_localize_script( 'dt_peoplegroups_scripts', 'dtPeopleGroupsAPI', build_people_groups_api_object() ); } + // Field settings for all post types: expose under dtOptionAPI for use by duplicate-fields and other settings + $post_field_settings = []; + if ( $_GET['page'] === 'dt_options' ) { + $post_types = DT_Posts::get_post_types(); + foreach ( $post_types as $post_type ) { + $post_field_settings[ $post_type ] = DT_Posts::get_post_field_settings( $post_type ); + } + } + + // Prepare duplicate fields data for general tab (read from database only; form processing is in tab-general.php) + $duplicate_fields_data = [ + 'config' => [], + 'post_types' => [], + 'fields' => [], + 'defaults' => [], + ]; + if ( $active_tab === 'general' ) { + $site_options = dt_get_option( 'dt_site_options' ); + $duplicates_config = $site_options['duplicates'] ?? []; + $duplicate_fields_data['config'] = $duplicates_config; + $duplicate_fields_data['post_types'] = array_values( DT_Posts::get_post_types() ); + $defaults_data = []; + foreach ( $duplicate_fields_data['post_types'] as $post_type ) { + $defaults_data[ $post_type ] = dt_get_duplicate_fields_defaults( $post_type ); + } + $duplicate_fields_data['fields'] = $post_field_settings; + $duplicate_fields_data['defaults'] = $defaults_data; + } + wp_localize_script( 'dt_options_script', 'dtOptionAPI', array( 'root' => esc_url_raw( rest_url() ), @@ -109,6 +145,8 @@ function dt_options_scripts() { 'available_languages' => dt_get_available_languages(), 'site_options' => dt_get_option( 'dt_site_options' ), 'contacts_field_settings' => DT_Posts::get_post_field_settings( 'contacts' ), + 'post_field_settings' => $post_field_settings, + 'duplicate_fields' => $duplicate_fields_data, ) ); wp_register_style( 'dt_admin_css', disciple_tools()->admin_css_url . 'disciple-tools-admin-styles.css', [], filemtime( disciple_tools()->admin_css_path . 'disciple-tools-admin-styles.css' ) ); diff --git a/dt-core/admin/js/dt-options.js b/dt-core/admin/js/dt-options.js index 0f79832f51..6667a1b346 100644 --- a/dt-core/admin/js/dt-options.js +++ b/dt-core/admin/js/dt-options.js @@ -1528,4 +1528,202 @@ jQuery(document).ready(function ($) { /** * Storage Test Connection - [END] */ + + /** + * Duplicate Fields Configuration + */ + if ( + window.dtOptionAPI && + window.dtOptionAPI.duplicate_fields && + window.dtOptionAPI.duplicate_fields.post_types && + Array.isArray(window.dtOptionAPI.duplicate_fields.post_types) && + window.dtOptionAPI.duplicate_fields.post_types.length > 0 && + $('#duplicate-fields-form').length > 0 + ) { + // Safe config: ensure object (avoid accessing from prototype) + const duplicateFieldsConfig = + window.dtOptionAPI.duplicate_fields.config != null && + typeof window.dtOptionAPI.duplicate_fields.config === 'object' && + !Array.isArray(window.dtOptionAPI.duplicate_fields.config) + ? window.dtOptionAPI.duplicate_fields.config + : {}; + // Ensure post_types is an array (handle case where it might be an object due to non-sequential keys) + const postTypesRaw = window.dtOptionAPI.duplicate_fields.post_types || []; + const allPostTypes = Array.isArray(postTypesRaw) + ? postTypesRaw + : Object.values(postTypesRaw); + // Prefer shared post_field_settings; fallback to duplicate_fields.fields for backward compatibility + const fieldsData = + window.dtOptionAPI.post_field_settings && + typeof window.dtOptionAPI.post_field_settings === 'object' + ? window.dtOptionAPI.post_field_settings + : window.dtOptionAPI.duplicate_fields.fields || {}; + const defaultFields = window.dtOptionAPI.duplicate_fields.defaults || {}; + + // Transform field settings to dt-multi-select format + // Note: These types must match those supported in extract_field_values_for_duplicate_search() + // in dt-contacts/duplicates-merging.php + function transformFieldsForMultiSelect(fields) { + const allowedTypes = [ + 'text', + 'textarea', + 'number', + 'communication_channel', + 'tags', + 'multi_select', + ]; + + const fieldsArray = Object.keys(fields || {}).map(function (fieldKey) { + const field = fields[fieldKey]; + return { + key: fieldKey, + name: field.name || fieldKey, + type: field.type, + hidden: field.hidden || false, + private: field.private || false, + icon: field.icon || field['font-icon'] || null, + }; + }); + + return fieldsArray + .filter(function (field) { + return ( + !field.hidden && !field.private && allowedTypes.includes(field.type) + ); + }) + .sort(function (a, b) { + const nameA = (a.name || '').toLowerCase(); + const nameB = (b.name || '').toLowerCase(); + return nameA.localeCompare(nameB); + }) + .map(function (field) { + return { + id: field.key, + label: field.name || field.key, + color: null, + icon: field.icon || null, + }; + }); + } + + // Update field selector with fields + function updateFieldSelector(postType) { + const fieldSelector = document.querySelector( + '#duplicate_fields_selector', + ); + if (!fieldSelector) return; + + const fields = fieldsData[postType] || {}; + const options = transformFieldsForMultiSelect(fields); + fieldSelector.options = options; + + // Set selected values from config, or use defaults if no valid config exists + // Check if valid config exists for this post type (use Object.prototype.hasOwnProperty.call to avoid unsafe hasOwnProperty from target object) + let selectedFields; + if ( + Object.prototype.hasOwnProperty.call(duplicateFieldsConfig, postType) && + Array.isArray(duplicateFieldsConfig[postType]) && + duplicateFieldsConfig[postType].length > 0 + ) { + // Valid saved config exists - use it + selectedFields = duplicateFieldsConfig[postType]; + } else { + // No valid config exists - use defaults + selectedFields = defaultFields[postType] || []; + } + + // Filter selectedFields to only include fields that exist in options + // This prevents errors when a field was configured but later removed or filtered out + const availableOptionIds = options.map(function (opt) { + return opt.id; + }); + const validSelectedFields = selectedFields.filter(function (fieldId) { + return availableOptionIds.includes(fieldId); + }); + + fieldSelector.value = validSelectedFields; + } + + // Handle post type change + $('#duplicate_fields_post_type').on('change', function () { + const postType = $(this).val(); + updateFieldSelector(postType); + }); + + // Handle form submission + $('#duplicate-fields-form').on('submit', function (e) { + e.preventDefault(); + + const postType = $('#duplicate_fields_post_type').val(); + const fieldSelector = document.querySelector( + '#duplicate_fields_selector', + ); + + if (!fieldSelector) { + alert('Field selector not found'); + return false; + } + + // Collect all post type configurations + const allConfigs = {}; + + // Get current selection - always save current selection, even if empty + // (empty will be handled by PHP to use defaults) + const currentFields = Array.isArray(fieldSelector.value) + ? fieldSelector.value + : []; + + // Always save the current post type selection (even if empty array) + // PHP will ensure 'name' is included and handle empty arrays + allConfigs[postType] = currentFields; + + // Include other post types from existing config + Object.keys(duplicateFieldsConfig).forEach(function (pt) { + if (pt !== postType && duplicateFieldsConfig[pt]) { + allConfigs[pt] = duplicateFieldsConfig[pt]; + } + }); + + // Set hidden input + $('#duplicate_fields_data').val(JSON.stringify(allConfigs)); + + // Submit form + this.submit(); + }); + + // Initialize on page load - wait for web component to be defined + function initializeDuplicateFields() { + const fieldSelector = document.querySelector( + '#duplicate_fields_selector', + ); + const postTypeSelect = $('#duplicate_fields_post_type'); + + if (!fieldSelector || !postTypeSelect.length) { + return; + } + + // Check if component is defined + if ( + window.customElements && + window.customElements.get('dt-multi-select') + ) { + // Component is ready, initialize with the currently selected post type + const selectedPostType = postTypeSelect.val(); + if (selectedPostType && fieldsData[selectedPostType]) { + updateFieldSelector(selectedPostType); + } else if (allPostTypes.length > 0) { + // Fallback to first post type if no selection + updateFieldSelector(allPostTypes[0]); + } + } else { + // Wait for component to be defined + setTimeout(initializeDuplicateFields, 100); + } + } + + // Start initialization + $(document).ready(function () { + initializeDuplicateFields(); + }); + } }); diff --git a/dt-core/admin/menu/tabs/tab-general.php b/dt-core/admin/menu/tabs/tab-general.php index bc2f5dc3ba..75bda3017b 100644 --- a/dt-core/admin/menu/tabs/tab-general.php +++ b/dt-core/admin/menu/tabs/tab-general.php @@ -124,6 +124,13 @@ public function content( $tab ) { $this->box( 'bottom' ); /* Contact Setup */ + /* Duplicate Detection Fields */ + $this->box( 'top', 'Duplicate Detection Fields' ); + $this->process_duplicate_fields(); + $this->display_duplicate_fields_settings(); + $this->box( 'bottom' ); + /* Duplicate Detection Fields */ + /* Custom Logo */ $this->box( 'top', 'Custom Logo' ); $this->process_custom_logo(); @@ -1012,6 +1019,142 @@ public static function admin_notice( string $notice, string $type ) { $fields ) { + // Validate post type exists + if ( !in_array( $post_type, $post_types ) ) { + continue; + } + + // Sanitize field keys + $sanitized_fields = []; + if ( is_array( $fields ) ) { + foreach ( $fields as $field_key ) { + $sanitized_field_key = sanitize_key( $field_key ); + + // Validate field exists for this post type + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + if ( isset( $field_settings[$sanitized_field_key] ) ) { + $sanitized_fields[] = $sanitized_field_key; + } + } + } + + // Save the configuration as-is (respect user's explicit choices) + // Note: 'name' will be included automatically via defaults if no config exists + // But if user explicitly saves a config, we respect their choice + if ( !empty( $sanitized_fields ) ) { + $duplicates_config[$post_type] = array_unique( $sanitized_fields ); + } + } + + $site_options['duplicates'] = $duplicates_config; + } + } else { + // If no data provided, set to empty array (will use defaults) + $site_options['duplicates'] = []; + } + + update_option( 'dt_site_options', $site_options, true ); + + // Clear any potential caches to ensure fresh data is read + wp_cache_delete( 'dt_site_options', 'options' ); + wp_cache_delete( 'alloptions', 'options' ); + } + } + + /** + * Display duplicate fields settings + */ + public function display_duplicate_fields_settings() { + $site_options = dt_get_option( 'dt_site_options' ); + $duplicates_config = $site_options['duplicates'] ?? []; + $post_types = DT_Posts::get_post_types(); + + // Get first post type for initial selection + $selected_post_type = !empty( $post_types ) ? $post_types[0] : ''; + + // Pre-load field settings for all post types + $fields_data = []; + foreach ( $post_types as $post_type ) { + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + $fields_data[$post_type] = $field_settings; + } + + ?> +
+ ['contacts' => ['name', 'contact_phone', ...], 'groups' => ['name', ...], ...]] + $fields['duplicates'] = []; $fields['group_preferences'] = [ 'church_metrics' => true, 'four_fields' => false, diff --git a/dt-core/global-functions.php b/dt-core/global-functions.php index 18b05e79cb..f360f5a53b 100755 --- a/dt-core/global-functions.php +++ b/dt-core/global-functions.php @@ -1519,6 +1519,40 @@ function dt_get_global_languages_list(){ } } + /** + * Get default duplicate detection fields for a post type + * + * Returns an array of field keys that should be checked for duplicates by default. + * - 'name' field is always included for all post types + * - 'communication_channel' type fields are included only for contacts post type + * + * Used by: admin general settings (duplicate fields UI) and duplicate detection logic + * in dt-contacts/duplicates-merging.php when no saved configuration exists. + * + * @param string $post_type The post type to get defaults for + * @return array Array of field keys to check for duplicates + */ + function dt_get_duplicate_fields_defaults( $post_type ) { + $default_fields = []; + + // Name field is always included for all post types + $default_fields[] = 'name'; + + // Communication channel fields are only included for contacts post type + if ( $post_type === 'contacts' ) { + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + foreach ( $field_settings as $field_key => $field_setting ) { + // Include all communication_channel type fields + if ( isset( $field_setting['type'] ) && $field_setting['type'] === 'communication_channel' ) { + $default_fields[] = $field_key; + } + } + } + + return apply_filters( 'dt_duplicate_fields_defaults', $default_fields, $post_type ); + } + /** * All code above here. */ diff --git a/tests/unit-test-duplicate-detection.php b/tests/unit-test-duplicate-detection.php new file mode 100644 index 0000000000..8e2e0aa9bd --- /dev/null +++ b/tests/unit-test-duplicate-detection.php @@ -0,0 +1,904 @@ +set_role( 'dispatcher' ); + self::$user_id = $user_id; + update_option( 'dt_base_user', $user_id ); + } + + public function setUp(): void { + parent::setUp(); + wp_set_current_user( self::$user_id ); + } + + /** + * @testdox dt_get_duplicate_fields_defaults returns name for any post type + */ + public function test_defaults_include_name() { + $defaults = dt_get_duplicate_fields_defaults( 'contacts' ); + $this->assertContains( 'name', $defaults ); + + $defaults_groups = dt_get_duplicate_fields_defaults( 'groups' ); + $this->assertContains( 'name', $defaults_groups ); + } + + /** + * @testdox dt_get_duplicate_fields_defaults includes communication channels for contacts + */ + public function test_defaults_include_communication_channels_for_contacts() { + $defaults = dt_get_duplicate_fields_defaults( 'contacts' ); + $this->assertContains( 'contact_phone', $defaults ); + $this->assertContains( 'contact_email', $defaults ); + } + + /** + * @testdox dt_get_duplicate_fields_defaults does not include communication channels for non-contact post types + */ + public function test_defaults_no_communication_channels_for_groups() { + $defaults = dt_get_duplicate_fields_defaults( 'groups' ); + $this->assertNotContains( 'contact_phone', $defaults ); + $this->assertNotContains( 'contact_email', $defaults ); + } + + /** + * @testdox Duplicate detection finds contacts with matching phone numbers using default config + */ + public function test_duplicate_detection_by_phone_default_config() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Alice Phone', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5551234567' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Alice Phone Copy', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5551234567' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + } + + /** + * @testdox Duplicate detection finds contacts with matching names using default config + */ + public function test_duplicate_detection_by_name_default_config() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'UniqueNameForDupTest123', + 'overall_status' => 'active', + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'UniqueNameForDupTest123', + 'overall_status' => 'active', + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + } + + /** + * @testdox Custom duplicate config restricts which fields are checked + */ + public function test_custom_config_restricts_fields() { + // Create two contacts that share a phone but have different names + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'DupConfigTest Alpha', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5559876543' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'DupConfigTest Beta', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5559876543' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // Configure duplicate detection to only check 'name' (not phone) + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'name' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + // Names are different, so should NOT be detected as duplicates when only checking name + $this->assertNotContains( (string) $contact2['ID'], $duplicates['ids'] ); + + // Now configure to check phone + $site_options['duplicates'] = [ 'contacts' => [ 'contact_phone' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + // Phone matches, so SHOULD be detected + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + + // Clean up + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Custom duplicate config with email field works + */ + public function test_custom_config_with_email() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Email Dup Test One', + 'overall_status' => 'active', + 'contact_email' => [ 'values' => [ [ 'value' => 'duptest@example.com' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Email Dup Test Two', + 'overall_status' => 'active', + 'contact_email' => [ 'values' => [ [ 'value' => 'duptest@example.com' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // Configure to only check email + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'contact_email' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + + // Clean up + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Empty custom config falls back to defaults + */ + public function test_empty_config_falls_back_to_defaults() { + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'FallbackDupTest', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5550001111' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'FallbackDupTest', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5550001111' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // With empty config, defaults should apply (name + communication channels) + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + } + + /** + * @testdox dt_get_duplicate_fields_defaults is filterable + */ + public function test_defaults_are_filterable() { + $filter = function ( $defaults, $post_type ) { + if ( $post_type === 'contacts' ) { + $defaults[] = 'nickname'; + } + return $defaults; + }; + add_filter( 'dt_duplicate_fields_defaults', $filter, 10, 2 ); + + $defaults = dt_get_duplicate_fields_defaults( 'contacts' ); + $this->assertContains( 'nickname', $defaults ); + + remove_filter( 'dt_duplicate_fields_defaults', $filter, 10 ); + } + + /** + * @testdox Site options include duplicates key in defaults + */ + public function test_site_options_defaults_include_duplicates_key() { + $defaults = dt_get_site_options_defaults(); + $this->assertArrayHasKey( 'duplicates', $defaults ); + $this->assertIsArray( $defaults['duplicates'] ); + $this->assertEmpty( $defaults['duplicates'] ); + } + + /** + * @testdox Duplicate detection works with tags field type + */ + public function test_duplicate_detection_by_tags() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Tags Dup Alpha', + 'overall_status' => 'active', + 'tags' => [ 'values' => [ [ 'value' => 'unique-dup-tag-xyz' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Tags Dup Beta', + 'overall_status' => 'active', + 'tags' => [ 'values' => [ [ 'value' => 'unique-dup-tag-xyz' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // Configure to check tags + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'tags' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + + // Clean up + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Duplicate detection does not match contacts with different tags + */ + public function test_no_duplicate_detection_with_different_tags() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Tags NoDup Alpha', + 'overall_status' => 'active', + 'tags' => [ 'values' => [ [ 'value' => 'tag-aaa-111' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'Tags NoDup Beta', + 'overall_status' => 'active', + 'tags' => [ 'values' => [ [ 'value' => 'tag-bbb-222' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'tags' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertNotContains( (string) $contact2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Duplicate detection works with multi_select field type + */ + public function test_duplicate_detection_by_multi_select() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MultiSel Dup Alpha', + 'overall_status' => 'active', + 'milestones' => [ 'values' => [ [ 'value' => 'milestone_has_bible' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MultiSel Dup Beta', + 'overall_status' => 'active', + 'milestones' => [ 'values' => [ [ 'value' => 'milestone_has_bible' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // Configure to check milestones (a multi_select field on contacts) + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'milestones' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Duplicate detection does not match contacts with different multi_select values + */ + public function test_no_duplicate_detection_with_different_multi_select() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MultiSel NoDup Alpha', + 'overall_status' => 'active', + 'milestones' => [ 'values' => [ [ 'value' => 'milestone_has_bible' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MultiSel NoDup Beta', + 'overall_status' => 'active', + 'milestones' => [ 'values' => [ [ 'value' => 'milestone_baptizing' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'milestones' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertNotContains( (string) $contact2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Duplicate detection works with text field type (nickname) + */ + public function test_duplicate_detection_by_text_field() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'TextDup Alpha', + 'overall_status' => 'active', + 'nickname' => 'the-same-unique-nickname-789', + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'TextDup Beta', + 'overall_status' => 'active', + 'nickname' => 'the-same-unique-nickname-789', + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // Configure to check nickname (a text field on contacts) + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'nickname' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Duplicate detection does not match contacts with different text field values + */ + public function test_no_duplicate_detection_with_different_text() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'TextNoDup Alpha', + 'overall_status' => 'active', + 'nickname' => 'nickname-aaa-unique', + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'TextNoDup Beta', + 'overall_status' => 'active', + 'nickname' => 'nickname-bbb-unique', + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'nickname' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertNotContains( (string) $contact2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox Duplicate detection with multiple configured field types finds match on any field + */ + public function test_duplicate_detection_with_mixed_field_types() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MixedDup Alpha', + 'overall_status' => 'active', + 'nickname' => 'mixed-nick-unique-456', + 'tags' => [ 'values' => [ [ 'value' => 'mixed-tag-unique-456' ] ] ], + 'contact_phone' => [ 'values' => [ [ 'value' => '5550009999' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + // Contact2 only shares tags, not nickname or phone + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MixedDup Beta', + 'overall_status' => 'active', + 'nickname' => 'different-nick', + 'tags' => [ 'values' => [ [ 'value' => 'mixed-tag-unique-456' ] ] ], + 'contact_phone' => [ 'values' => [ [ 'value' => '5550008888' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + // Configure to check nickname, tags, and phone + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'nickname', 'tags', 'contact_phone' ] ]; + update_option( 'dt_site_options', $site_options ); + + // Should match because tags overlap + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $contact2['ID'], $duplicates['ids'] ); + + // Now configure to only check nickname — should NOT match + $site_options['duplicates'] = [ 'contacts' => [ 'nickname' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertNotContains( (string) $contact2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + // ========================================================================= + // get_all_duplicates_on_post: exact + fuzzy matching with scoring + // ========================================================================= + + /** + * Helper to find a specific duplicate ID in get_all_duplicates_on_post results. + */ + private function find_dup_in_results( array $results, int $target_id ) { + foreach ( $results as $dup ) { + if ( (int) $dup['ID'] === $target_id ) { + return $dup; + } + } + return null; + } + + /** + * @testdox get_all_duplicates_on_post finds exact name match with high points + */ + public function test_all_duplicates_exact_name_match() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'ExactNameAllDups777', + 'overall_status' => 'active', + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'ExactNameAllDups777', + 'overall_status' => 'active', + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'name' ] ]; + update_option( 'dt_site_options', $site_options ); + + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $results ); + $this->assertIsArray( $results ); + + $match = $this->find_dup_in_results( $results, $contact2['ID'] ); + $this->assertNotNull( $match, 'Exact name match should be found' ); + // Exact text match = 4 points + $this->assertGreaterThanOrEqual( 4, $match['points'] ); + + // Verify match_on fields contain the name field + $matched_fields = array_column( $match['fields'], 'field' ); + $this->assertContains( 'name', $matched_fields ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox get_all_duplicates_on_post finds fuzzy name match (substring) with lower points + */ + public function test_all_duplicates_fuzzy_name_match() { + // Contact with the longer name + $contact_long = DT_Posts::create_post( 'contacts', [ + 'title' => 'FuzzyTestJohnathan', + 'overall_status' => 'active', + ], true, false ); + $this->assertNotWPError( $contact_long ); + + // Contact with the shorter name (substring of the longer) + $contact_short = DT_Posts::create_post( 'contacts', [ + 'title' => 'FuzzyTestJohn', + 'overall_status' => 'active', + ], true, false ); + $this->assertNotWPError( $contact_short ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'name' ] ]; + update_option( 'dt_site_options', $site_options ); + + // Search from the shorter-named contact: the DB LIKE '%FuzzyTestJohn%' + // will match "FuzzyTestJohnathan", so it gets returned as a candidate. + // Then the scoring logic detects the substring relationship. + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'contacts', $contact_short['ID'] ); + $this->assertNotWPError( $results ); + + $match = $this->find_dup_in_results( $results, $contact_long['ID'] ); + $this->assertNotNull( $match, 'Fuzzy name match (substring) should be found' ); + // Fuzzy match = 1 point (not 4 for exact) + $this->assertGreaterThanOrEqual( 1, $match['points'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox get_all_duplicates_on_post scores exact phone match with 4 points + */ + public function test_all_duplicates_exact_phone_match_scoring() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'PhoneScore Alpha', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5557770001' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'PhoneScore Beta', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5557770001' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'contact_phone' ] ]; + update_option( 'dt_site_options', $site_options ); + + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $results ); + + $match = $this->find_dup_in_results( $results, $contact2['ID'] ); + $this->assertNotNull( $match, 'Exact phone match should be found' ); + $this->assertGreaterThanOrEqual( 4, $match['points'] ); + + $matched_fields = array_column( $match['fields'], 'field' ); + $this->assertContains( 'contact_phone', $matched_fields ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox get_all_duplicates_on_post finds exact tags match with scoring + */ + public function test_all_duplicates_exact_tags_match() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'TagsAllDup Alpha', + 'overall_status' => 'active', + 'tags' => [ 'values' => [ [ 'value' => 'all-dup-tag-exact-999' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'TagsAllDup Beta', + 'overall_status' => 'active', + 'tags' => [ 'values' => [ [ 'value' => 'all-dup-tag-exact-999' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'tags' ] ]; + update_option( 'dt_site_options', $site_options ); + + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $results ); + + $match = $this->find_dup_in_results( $results, $contact2['ID'] ); + $this->assertNotNull( $match, 'Exact tags match should be found' ); + $this->assertGreaterThanOrEqual( 4, $match['points'] ); + + $matched_fields = array_column( $match['fields'], 'field' ); + $this->assertContains( 'tags', $matched_fields ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox get_all_duplicates_on_post finds exact multi_select match with scoring + */ + public function test_all_duplicates_exact_multi_select_match() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MSAllDup Alpha', + 'overall_status' => 'active', + 'milestones' => [ 'values' => [ [ 'value' => 'milestone_has_bible' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'MSAllDup Beta', + 'overall_status' => 'active', + 'milestones' => [ 'values' => [ [ 'value' => 'milestone_has_bible' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'milestones' ] ]; + update_option( 'dt_site_options', $site_options ); + + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $results ); + + $match = $this->find_dup_in_results( $results, $contact2['ID'] ); + $this->assertNotNull( $match, 'Exact multi_select match should be found' ); + $this->assertGreaterThanOrEqual( 4, $match['points'] ); + + $matched_fields = array_column( $match['fields'], 'field' ); + $this->assertContains( 'milestones', $matched_fields ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox get_all_duplicates_on_post exact match scores higher than fuzzy + */ + public function test_all_duplicates_exact_scores_higher_than_fuzzy() { + // The "source" contact uses the shorter nickname so the fuzzy DB search + // (LIKE '%ScoreCompare%') can find the longer "ScoreCompareNickLonger" too. + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'ScoreCompare Alpha', + 'overall_status' => 'active', + 'nickname' => 'ScoreCompare', + ], true, false ); + $this->assertNotWPError( $contact1 ); + + // Exact nickname match + $contact_exact = DT_Posts::create_post( 'contacts', [ + 'title' => 'ScoreCompare Exact', + 'overall_status' => 'active', + 'nickname' => 'ScoreCompare', + ], true, false ); + $this->assertNotWPError( $contact_exact ); + + // Fuzzy nickname match (contact1's nickname is a substring of this one) + $contact_fuzzy = DT_Posts::create_post( 'contacts', [ + 'title' => 'ScoreCompare Fuzzy', + 'overall_status' => 'active', + 'nickname' => 'ScoreCompareNickLonger', + ], true, false ); + $this->assertNotWPError( $contact_fuzzy ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'contacts' => [ 'nickname' ] ]; + update_option( 'dt_site_options', $site_options ); + + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'contacts', $contact1['ID'] ); + $this->assertNotWPError( $results ); + + $exact_match = $this->find_dup_in_results( $results, $contact_exact['ID'] ); + $fuzzy_match = $this->find_dup_in_results( $results, $contact_fuzzy['ID'] ); + + $this->assertNotNull( $exact_match, 'Exact match should be found' ); + $this->assertNotNull( $fuzzy_match, 'Fuzzy match should be found' ); + $this->assertGreaterThan( $fuzzy_match['points'], $exact_match['points'], 'Exact match should score higher than fuzzy' ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + // ========================================================================= + // Create-time duplicate check (check_for_duplicates arg) — unchanged path + // ========================================================================= + + /** + * @testdox Create-time check_for_duplicates on phone still works and updates existing record + */ + public function test_create_time_duplicate_check_by_phone() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'CreateDup Phone Original', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5551112222' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + // Create with duplicate check — should update existing instead of creating new + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'CreateDup Phone Duplicate', + 'contact_phone' => [ 'values' => [ [ 'value' => '5551112222' ] ] ], + ], true, false, [ + 'check_for_duplicates' => [ 'contact_phone' ], + ] ); + $this->assertNotWPError( $contact2 ); + + // Should return the original contact's ID (updated, not new) + $this->assertSame( $contact1['ID'], $contact2['ID'] ); + } + + /** + * @testdox Create-time check_for_duplicates on email still works and updates existing record + */ + public function test_create_time_duplicate_check_by_email() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'CreateDup Email Original', + 'overall_status' => 'active', + 'contact_email' => [ 'values' => [ [ 'value' => 'create-dup-test@example.com' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + // Create with duplicate check on email — should update existing + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'CreateDup Email Duplicate', + 'contact_email' => [ 'values' => [ [ 'value' => 'create-dup-test@example.com' ] ] ], + 'contact_phone' => [ 'values' => [ [ 'value' => '5553334444' ] ] ], + ], true, false, [ + 'check_for_duplicates' => [ 'contact_email' ], + ] ); + $this->assertNotWPError( $contact2 ); + + // Should return the original contact's ID (updated, not new) + $this->assertSame( $contact1['ID'], $contact2['ID'] ); + + // Verify the phone was added to the existing contact + $updated = DT_Posts::get_post( 'contacts', $contact1['ID'] ); + $phones = array_column( $updated['contact_phone'], 'value' ); + $this->assertContains( '5553334444', $phones ); + } + + /** + * @testdox Create-time check_for_duplicates creates new record when no match + */ + public function test_create_time_duplicate_check_no_match_creates_new() { + $contact1 = DT_Posts::create_post( 'contacts', [ + 'title' => 'CreateNoDup Original', + 'overall_status' => 'active', + 'contact_phone' => [ 'values' => [ [ 'value' => '5556667777' ] ] ], + ], true, false ); + $this->assertNotWPError( $contact1 ); + + // Create with different phone — should create new record + $contact2 = DT_Posts::create_post( 'contacts', [ + 'title' => 'CreateNoDup Different', + 'contact_phone' => [ 'values' => [ [ 'value' => '5558889999' ] ] ], + ], true, false, [ + 'check_for_duplicates' => [ 'contact_phone' ], + ] ); + $this->assertNotWPError( $contact2 ); + + $this->assertNotSame( $contact1['ID'], $contact2['ID'] ); + } + + // ========================================================================= + // Groups post type — duplicate detection works across post types + // ========================================================================= + + /** + * @testdox Duplicate detection finds groups with matching names using default config + */ + public function test_groups_duplicate_detection_by_name_default_config() { + $group1 = DT_Posts::create_post( 'groups', [ + 'title' => 'UniqueGroupDupTest789', + ], true, false ); + $this->assertNotWPError( $group1 ); + + $group2 = DT_Posts::create_post( 'groups', [ + 'title' => 'UniqueGroupDupTest789', + ], true, false ); + $this->assertNotWPError( $group2 ); + + // Default config for groups is just ['name'] + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'groups', $group1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $group2['ID'], $duplicates['ids'] ); + } + + /** + * @testdox Duplicate detection on groups does not match different names + */ + public function test_groups_no_duplicate_with_different_names() { + $group1 = DT_Posts::create_post( 'groups', [ + 'title' => 'GroupNoDup Alpha', + ], true, false ); + $this->assertNotWPError( $group1 ); + + $group2 = DT_Posts::create_post( 'groups', [ + 'title' => 'GroupNoDup Beta', + ], true, false ); + $this->assertNotWPError( $group2 ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'groups', $group1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertNotContains( (string) $group2['ID'], $duplicates['ids'] ); + } + + /** + * @testdox Custom duplicate config works for groups post type + */ + public function test_groups_custom_config_with_tags() { + $group1 = DT_Posts::create_post( 'groups', [ + 'title' => 'GroupTagDup Alpha', + 'tags' => [ 'values' => [ [ 'value' => 'group-dup-tag-unique-321' ] ] ], + ], true, false ); + $this->assertNotWPError( $group1 ); + + $group2 = DT_Posts::create_post( 'groups', [ + 'title' => 'GroupTagDup Beta', + 'tags' => [ 'values' => [ [ 'value' => 'group-dup-tag-unique-321' ] ] ], + ], true, false ); + $this->assertNotWPError( $group2 ); + + // Configure groups to check tags + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'groups' => [ 'tags' ] ]; + update_option( 'dt_site_options', $site_options ); + + $duplicates = DT_Duplicate_Checker_And_Merging::ids_of_non_dismissed_duplicates( 'groups', $group1['ID'] ); + $this->assertNotWPError( $duplicates ); + $this->assertContains( (string) $group2['ID'], $duplicates['ids'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } + + /** + * @testdox get_all_duplicates_on_post works for groups with exact and fuzzy name matching + */ + public function test_groups_all_duplicates_exact_and_fuzzy_name() { + $group1 = DT_Posts::create_post( 'groups', [ + 'title' => 'FuzzyGroup', + ], true, false ); + $this->assertNotWPError( $group1 ); + + // Exact match + $group_exact = DT_Posts::create_post( 'groups', [ + 'title' => 'FuzzyGroup', + ], true, false ); + $this->assertNotWPError( $group_exact ); + + // Fuzzy match (group1's name is a substring) + $group_fuzzy = DT_Posts::create_post( 'groups', [ + 'title' => 'FuzzyGroupExtended', + ], true, false ); + $this->assertNotWPError( $group_fuzzy ); + + $site_options = dt_get_option( 'dt_site_options' ); + $site_options['duplicates'] = [ 'groups' => [ 'name' ] ]; + update_option( 'dt_site_options', $site_options ); + + $results = DT_Duplicate_Checker_And_Merging::get_all_duplicates_on_post( 'groups', $group1['ID'] ); + $this->assertNotWPError( $results ); + + $exact_match = $this->find_dup_in_results( $results, $group_exact['ID'] ); + $fuzzy_match = $this->find_dup_in_results( $results, $group_fuzzy['ID'] ); + + $this->assertNotNull( $exact_match, 'Exact group name match should be found' ); + $this->assertNotNull( $fuzzy_match, 'Fuzzy group name match should be found' ); + $this->assertGreaterThan( $fuzzy_match['points'], $exact_match['points'] ); + + $site_options['duplicates'] = []; + update_option( 'dt_site_options', $site_options ); + } +}