From e46334fb2ff2edba667bfaef6130ae8d2086312d Mon Sep 17 00:00:00 2001 From: kodinkat Date: Thu, 5 Feb 2026 11:03:14 +0000 Subject: [PATCH 01/16] Enhance modular-list.js with Foundation checks - Added checks for the existence of Foundation components before initializing the Accordion and MediaQuery functions to prevent potential errors in environments where Foundation is not loaded. - Improved mobile filter collapse functionality by ensuring Foundation is available before executing related logic. --- dt-assets/js/modular-list.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/dt-assets/js/modular-list.js b/dt-assets/js/modular-list.js index a0f0e39a62..01831c8cba 100644 --- a/dt-assets/js/modular-list.js +++ b/dt-assets/js/modular-list.js @@ -311,7 +311,7 @@ // Collapse filter tile for mobile view function collapse_filters() { - if (window.Foundation.MediaQuery.only('small')) { + if (window.Foundation && window.Foundation.MediaQuery && window.Foundation.MediaQuery.only('small')) { $('#list-filters .bordered-box').addClass('collapsed'); } else { $('#list-filters .bordered-box').removeClass('collapsed'); @@ -699,10 +699,13 @@ ), ); }); - new window.Foundation.Accordion(filter_accordions, { - slideSpeed: 100, - allowAllClosed: true, - }); + // Initialize Foundation Accordion if available + if (window.Foundation && window.Foundation.Accordion && filter_accordions.length) { + new window.Foundation.Accordion(filter_accordions, { + slideSpeed: 100, + allowAllClosed: true, + }); + } if (selected_tab) { $( `#list-filter-tabs [data-id='${window.SHAREDFUNCTIONS.escapeHTML(selected_tab)}'] a`, @@ -1722,7 +1725,7 @@ search_query, new_filter_labels, ); - if (window.Foundation.MediaQuery.only('small')) { + if (window.Foundation && window.Foundation.MediaQuery && window.Foundation.MediaQuery.only('small')) { $('#tile-filters').addClass('collapsed'); } }); From 75fe35d0c9b61ebabc8dd5d7fc140c8278905aa6 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Thu, 5 Feb 2026 13:34:09 +0000 Subject: [PATCH 02/16] Implement duplicate detection configuration in admin settings - Added functionality to configure fields for duplicate detection in the general settings tab. - Introduced a new form for selecting fields to check for duplicates, ensuring the 'name' field is always included. - Enhanced JavaScript to manage field selection and submission, utilizing a multi-select component for better user experience. - Created helper functions to extract field values based on type for duplicate searches. - Updated site options to store duplicate field configurations, allowing for flexible management of duplicate detection across post types. --- dt-contacts/duplicates-merging.php | 123 ++++++++++-- dt-core/admin/admin-enqueue-scripts.php | 26 +++ dt-core/admin/js/dt-options.js | 175 ++++++++++++++++++ dt-core/admin/menu/tabs/tab-general.php | 141 ++++++++++++++ .../configuration/config-site-defaults.php | 5 + dt-core/global-functions.php | 33 ++++ 6 files changed, 485 insertions(+), 18 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index 7857140084..245195e670 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -95,35 +95,122 @@ 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 + 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[] = $exact_template . $tag_value; + } + } + } + break; + + case 'multi_select': + // Array of selected keys + 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[] = $exact_template . $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 empty + $site_options = dt_get_option( 'dt_site_options' ); + $configured_fields = $site_options['duplicates'][$post_type] ?? null; + + // If no configuration exists, use defaults + if ( empty( $configured_fields ) ){ + $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, diff --git a/dt-core/admin/admin-enqueue-scripts.php b/dt-core/admin/admin-enqueue-scripts.php index c942ba7a18..99d42487dd 100644 --- a/dt-core/admin/admin-enqueue-scripts.php +++ b/dt-core/admin/admin-enqueue-scripts.php @@ -90,6 +90,10 @@ 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' ); + // Enqueue web components for dt-multi-select and other components + 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 ( isset( $_GET['tab'] ) && ( ( $_GET['tab'] === 'people-groups' ) || ( $_GET['tab'] === 'general' ) ) ) { wp_enqueue_script( 'dt_peoplegroups_scripts', get_template_directory_uri() . '/dt-people-groups/people-groups.js', [ 'jquery', @@ -98,6 +102,27 @@ function dt_options_scripts() { wp_localize_script( 'dt_peoplegroups_scripts', 'dtPeopleGroupsAPI', build_people_groups_api_object() ); } + // Prepare duplicate fields data for general tab + $duplicate_fields_data = []; + if ( isset( $_GET['tab'] ) && $_GET['tab'] === 'general' ) { + $site_options = dt_get_option( 'dt_site_options' ); + $duplicate_fields_data['config'] = $site_options['duplicates'] ?? []; + $duplicate_fields_data['post_types'] = DT_Posts::get_post_types(); + + // Pre-load field settings and defaults for all post types + $fields_data = []; + $defaults_data = []; + foreach ( $duplicate_fields_data['post_types'] as $post_type ) { + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + $fields_data[$post_type] = $field_settings; + + // Get default fields for this post type + $defaults_data[$post_type] = dt_get_duplicate_fields_defaults( $post_type ); + } + $duplicate_fields_data['fields'] = $fields_data; + $duplicate_fields_data['defaults'] = $defaults_data; + } + wp_localize_script( 'dt_options_script', 'dtOptionAPI', array( 'root' => esc_url_raw( rest_url() ), @@ -109,6 +134,7 @@ 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' ), + '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..c036f84e9e 100644 --- a/dt-core/admin/js/dt-options.js +++ b/dt-core/admin/js/dt-options.js @@ -1528,4 +1528,179 @@ jQuery(document).ready(function ($) { /** * Storage Test Connection - [END] */ + + /** + * Duplicate Fields Configuration + */ + if ( + window.dtOptionAPI && + window.dtOptionAPI.duplicate_fields && + Object.keys(window.dtOptionAPI.duplicate_fields).length > 0 && + $('#duplicate-fields-form').length > 0 + ) { + const duplicateFieldsConfig = + window.dtOptionAPI.duplicate_fields.config || {}; + const allPostTypes = + window.dtOptionAPI.duplicate_fields.post_types || []; + const fieldsData = 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 config exists + let selectedFields = duplicateFieldsConfig[postType]; + if (!selectedFields || selectedFields.length === 0) { + // Use defaults if no configuration exists for this post type + 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 + const currentFields = Array.isArray(fieldSelector.value) + ? fieldSelector.value + : []; + if (currentFields.length > 0) { + 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..d4694cc06c 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,140 @@ 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; + } + } + } + + // Ensure 'name' field is always included + if ( !in_array( 'name', $sanitized_fields ) ) { + array_unshift( $sanitized_fields, 'name' ); + } + + // Only save if we have fields + 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 ); + } + } + + /** + * 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..0edec3f5f9 100755 --- a/dt-core/global-functions.php +++ b/dt-core/global-functions.php @@ -1519,6 +1519,39 @@ 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 + * + * @param string $post_type The post type to get defaults for + * @return array Array of field keys to check for duplicates + */ + if ( !function_exists( 'dt_get_duplicate_fields_defaults' ) ) { + 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. */ From 779840b69208511b9b9fc6c31eb64b396d59cab7 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Thu, 5 Feb 2026 15:24:03 +0000 Subject: [PATCH 03/16] Refactor JavaScript for improved Foundation integration - Enhanced the collapse_filters function in modular-list.js for better readability by formatting conditional checks. - Updated contacts.js to ensure the merge duplicate modal is properly initialized before opening, adding error handling for missing Foundation components and modal instances. --- dt-assets/js/modular-list.js | 18 +++++++++++++++--- dt-contacts/contacts.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/dt-assets/js/modular-list.js b/dt-assets/js/modular-list.js index 01831c8cba..68b275a523 100644 --- a/dt-assets/js/modular-list.js +++ b/dt-assets/js/modular-list.js @@ -311,7 +311,11 @@ // Collapse filter tile for mobile view function collapse_filters() { - if (window.Foundation && window.Foundation.MediaQuery && window.Foundation.MediaQuery.only('small')) { + if ( + window.Foundation && + window.Foundation.MediaQuery && + window.Foundation.MediaQuery.only('small') + ) { $('#list-filters .bordered-box').addClass('collapsed'); } else { $('#list-filters .bordered-box').removeClass('collapsed'); @@ -700,7 +704,11 @@ ); }); // Initialize Foundation Accordion if available - if (window.Foundation && window.Foundation.Accordion && filter_accordions.length) { + if ( + window.Foundation && + window.Foundation.Accordion && + filter_accordions.length + ) { new window.Foundation.Accordion(filter_accordions, { slideSpeed: 100, allowAllClosed: true, @@ -1725,7 +1733,11 @@ search_query, new_filter_labels, ); - if (window.Foundation && window.Foundation.MediaQuery && window.Foundation.MediaQuery.only('small')) { + if ( + window.Foundation && + window.Foundation.MediaQuery && + window.Foundation.MediaQuery.only('small') + ) { $('#tile-filters').addClass('collapsed'); } }); diff --git a/dt-contacts/contacts.js b/dt-contacts/contacts.js index 8dfd219a2d..a10dc087a8 100644 --- a/dt-contacts/contacts.js +++ b/dt-contacts/contacts.js @@ -108,8 +108,38 @@ jQuery(document).ready(function ($) { } }); let merge_dupe_edit_modal = $('#merge-dupe-edit-modal'); + $(document).on('click', '#duplicates-detected-notice', function () { - merge_dupe_edit_modal.foundation('open'); + // Ensure modal exists + if (!merge_dupe_edit_modal.length) { + console.error('Merge duplicate modal not found'); + return; + } + + // Check if Foundation is initialized on this element + let foundationInstance = merge_dupe_edit_modal.data('zfPlugin'); + + if (!foundationInstance) { + // Foundation might not be initialized yet, try to initialize it + if (typeof Foundation !== 'undefined' && Foundation.Reveal) { + // Initialize Foundation Reveal on this element + foundationInstance = new Foundation.Reveal(merge_dupe_edit_modal); + } else { + console.error('Foundation library not available'); + return; + } + } + + // Open the modal using the Foundation instance + if (foundationInstance && typeof foundationInstance.open === 'function') { + foundationInstance.open(); + } else if (typeof merge_dupe_edit_modal.foundation === 'function') { + merge_dupe_edit_modal.foundation('open'); + } else { + console.error( + 'Unable to open modal - Foundation not properly initialized', + ); + } }); let possible_duplicates = []; From 0a1b28784e7ef5b05e11f47b934763dc5218eb83 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Thu, 5 Feb 2026 15:25:23 +0000 Subject: [PATCH 04/16] Refactor duplicate fields configuration handling in admin settings - Enhanced the logic for processing duplicate fields configuration in the general settings tab, ensuring that user-defined settings are respected and defaults are applied when necessary. - Improved JavaScript to handle form submissions for duplicate fields, including validation and sanitization of input data. - Updated PHP to clear cache after saving settings, ensuring fresh data is read on subsequent requests. - Streamlined the extraction of field values for duplicate searches, improving code readability and maintainability. --- dt-contacts/duplicates-merging.php | 57 +++++++++++++----------- dt-core/admin/admin-enqueue-scripts.php | 59 ++++++++++++++++++++++++- dt-core/admin/js/dt-options.js | 35 +++++++++------ dt-core/admin/menu/tabs/tab-general.php | 16 ++++--- 4 files changed, 119 insertions(+), 48 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index 245195e670..d1666a4808 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -88,7 +88,7 @@ public function get_ids_of_non_dismissed_duplicates_endpoint( WP_REST_Request $r $post_id = $params['id'] ?? null; $post_type = $params['post_type'] ?? null; if ( $post_id ){ - return self::ids_of_non_dismissed_duplicates( $post_type, $post_id ); + return self::ids_of_non_dismissed_duplicates( $post_type, $post_id, false ); } else { return new WP_Error( __FUNCTION__, 'Missing field for request', [ 'status' => 400 ] ); } @@ -97,7 +97,7 @@ 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 @@ -108,16 +108,16 @@ private static function extract_field_values_for_duplicate_search( $post, $field 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': @@ -125,7 +125,7 @@ private static function extract_field_values_for_duplicate_search( $post, $field // 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 ) ){ @@ -136,7 +136,7 @@ private static function extract_field_values_for_duplicate_search( $post, $field } } break; - + case 'tags': // Array of tag values if ( is_array( $field_value ) ){ @@ -148,7 +148,7 @@ private static function extract_field_values_for_duplicate_search( $post, $field } } break; - + case 'multi_select': // Array of selected keys if ( is_array( $field_value ) ){ @@ -160,7 +160,7 @@ private static function extract_field_values_for_duplicate_search( $post, $field } } break; - + default: // For other types, try to extract as string if ( is_scalar( $field_value ) ){ @@ -170,7 +170,7 @@ private static function extract_field_values_for_duplicate_search( $post, $field } break; } - + return !empty( $search_values ) ? $search_values : null; } @@ -180,37 +180,42 @@ private static function query_for_duplicate_searches( $post_type, $post_id, $exa $search_query = []; $exact_template = $exact ? '^' : ''; $fields_with_values = []; - - // Get configured duplicate fields, or use defaults if empty + + // Get configured duplicate fields, or use defaults if not configured $site_options = dt_get_option( 'dt_site_options' ); - $configured_fields = $site_options['duplicates'][$post_type] ?? null; - - // If no configuration exists, use defaults - if ( empty( $configured_fields ) ){ + $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; } - + // Extract values based on field type - $search_values = self::extract_field_values_for_duplicate_search( - $post, - $field_key, - $fields[ $field_key ], - $exact_template + $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 ] = $search_values; } } - + return [ 'query' => $search_query, 'fields' => $fields_with_values, diff --git a/dt-core/admin/admin-enqueue-scripts.php b/dt-core/admin/admin-enqueue-scripts.php index 99d42487dd..2979fa640b 100644 --- a/dt-core/admin/admin-enqueue-scripts.php +++ b/dt-core/admin/admin-enqueue-scripts.php @@ -105,8 +105,63 @@ function dt_options_scripts() { // Prepare duplicate fields data for general tab $duplicate_fields_data = []; if ( isset( $_GET['tab'] ) && $_GET['tab'] === 'general' ) { - $site_options = dt_get_option( 'dt_site_options' ); - $duplicate_fields_data['config'] = $site_options['duplicates'] ?? []; + // Check if we're processing a duplicate fields form submission + // If so, read from POST data to get the latest values before they're saved + $duplicates_config = null; // Use null to distinguish "no POST data" from "empty config" + $has_post_data = false; + + if ( isset( $_POST['duplicate_fields_nonce'] ) && + wp_verify_nonce( sanitize_key( wp_unslash( $_POST['duplicate_fields_nonce'] ) ), 'duplicate_fields' ) && + isset( $_POST['duplicate_fields_data'] ) && !empty( $_POST['duplicate_fields_data'] ) ) { + // Form is being submitted - read from POST to get the latest data + $has_post_data = true; + $decoded_data = json_decode( wp_unslash( $_POST['duplicate_fields_data'] ), true ); + if ( is_array( $decoded_data ) ) { + $duplicates_config = []; + $post_types = DT_Posts::get_post_types(); + foreach ( $decoded_data as $post_type => $fields ) { + if ( in_array( $post_type, $post_types ) && is_array( $fields ) ) { + // Handle both empty arrays (user cleared all fields) and non-empty arrays + if ( empty( $fields ) ) { + // Empty array means user wants to revert to defaults + // Don't set the key, so it will use defaults + // Explicitly don't add this post type to config + } else { + // Non-empty array - sanitize and save + $sanitized_fields = []; + foreach ( $fields as $field_key ) { + $sanitized_field_key = sanitize_key( $field_key ); + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + if ( isset( $field_settings[$sanitized_field_key] ) ) { + $sanitized_fields[] = $sanitized_field_key; + } + } + if ( !empty( $sanitized_fields ) ) { + $duplicates_config[$post_type] = array_unique( $sanitized_fields ); + } + // If sanitized_fields is empty (all invalid), don't add to config (use defaults) + } + } + } + } + } + + // If we didn't get data from POST, read from database + if ( !$has_post_data ) { + // Clear cache to ensure we read fresh data + wp_cache_delete( 'dt_site_options', 'options' ); + wp_cache_delete( 'alloptions', 'options' ); + $site_options = dt_get_option( 'dt_site_options' ); + $duplicates_config = $site_options['duplicates'] ?? []; + } else { + // We have POST data - use it (even if empty array, which means use defaults) + // $duplicates_config is already set above + if ( $duplicates_config === null ) { + $duplicates_config = []; + } + } + + $duplicate_fields_data['config'] = $duplicates_config; $duplicate_fields_data['post_types'] = DT_Posts::get_post_types(); // Pre-load field settings and defaults for all post types diff --git a/dt-core/admin/js/dt-options.js b/dt-core/admin/js/dt-options.js index c036f84e9e..5eab047676 100644 --- a/dt-core/admin/js/dt-options.js +++ b/dt-core/admin/js/dt-options.js @@ -1540,8 +1540,7 @@ jQuery(document).ready(function ($) { ) { const duplicateFieldsConfig = window.dtOptionAPI.duplicate_fields.config || {}; - const allPostTypes = - window.dtOptionAPI.duplicate_fields.post_types || []; + const allPostTypes = window.dtOptionAPI.duplicate_fields.post_types || []; const fieldsData = window.dtOptionAPI.duplicate_fields.fields || {}; const defaultFields = window.dtOptionAPI.duplicate_fields.defaults || {}; @@ -1573,9 +1572,7 @@ jQuery(document).ready(function ($) { return fieldsArray .filter(function (field) { return ( - !field.hidden && - !field.private && - allowedTypes.includes(field.type) + !field.hidden && !field.private && allowedTypes.includes(field.type) ); }) .sort(function (a, b) { @@ -1604,10 +1601,18 @@ jQuery(document).ready(function ($) { const options = transformFieldsForMultiSelect(fields); fieldSelector.options = options; - // Set selected values from config, or use defaults if no config exists - let selectedFields = duplicateFieldsConfig[postType]; - if (!selectedFields || selectedFields.length === 0) { - // Use defaults if no configuration exists for this post type + // Set selected values from config, or use defaults if no valid config exists + // Check if valid config exists for this post type + let selectedFields; + if ( + duplicateFieldsConfig.hasOwnProperty(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] || []; } @@ -1646,13 +1651,15 @@ jQuery(document).ready(function ($) { // Collect all post type configurations const allConfigs = {}; - // Get current selection + // 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 : []; - if (currentFields.length > 0) { - allConfigs[postType] = currentFields; - } + + // 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) { @@ -1674,7 +1681,7 @@ jQuery(document).ready(function ($) { '#duplicate_fields_selector', ); const postTypeSelect = $('#duplicate_fields_post_type'); - + if (!fieldSelector || !postTypeSelect.length) { return; } diff --git a/dt-core/admin/menu/tabs/tab-general.php b/dt-core/admin/menu/tabs/tab-general.php index d4694cc06c..0067502b44 100644 --- a/dt-core/admin/menu/tabs/tab-general.php +++ b/dt-core/admin/menu/tabs/tab-general.php @@ -1060,12 +1060,9 @@ public function process_duplicate_fields() { } } - // Ensure 'name' field is always included - if ( !in_array( 'name', $sanitized_fields ) ) { - array_unshift( $sanitized_fields, 'name' ); - } - - // Only save if we have fields + // 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 ); } @@ -1079,6 +1076,10 @@ public function process_duplicate_fields() { } 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' ); } } @@ -1086,6 +1087,9 @@ public function process_duplicate_fields() { * Display duplicate fields settings */ public function display_duplicate_fields_settings() { + // Read fresh data (bypass any potential cache) + // This ensures we get the latest saved configuration + wp_cache_delete( 'dt_site_options', 'options' ); $site_options = dt_get_option( 'dt_site_options' ); $duplicates_config = $site_options['duplicates'] ?? []; $post_types = DT_Posts::get_post_types(); From 73f392b391fb2c364c01c46cb7ba03ccdaab3d1a Mon Sep 17 00:00:00 2001 From: kodinkat Date: Thu, 5 Feb 2026 15:49:06 +0000 Subject: [PATCH 05/16] Refactor duplicate fields handling in global functions and admin scripts - Cleaned up whitespace and improved code readability in the duplicate fields configuration functions. - Enhanced the logic for processing duplicate fields in the admin settings, ensuring proper validation and sanitization of input data. - Streamlined the handling of default fields for duplicate detection across post types, maintaining user-defined settings while applying defaults where necessary. - Updated comments for clarity and maintainability throughout the affected files. --- dt-core/admin/admin-enqueue-scripts.php | 20 ++++++------ dt-core/admin/menu/tabs/tab-general.php | 41 +++++++++++++------------ dt-core/global-functions.php | 12 ++++---- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/dt-core/admin/admin-enqueue-scripts.php b/dt-core/admin/admin-enqueue-scripts.php index 2979fa640b..701d0dcadc 100644 --- a/dt-core/admin/admin-enqueue-scripts.php +++ b/dt-core/admin/admin-enqueue-scripts.php @@ -109,12 +109,13 @@ function dt_options_scripts() { // If so, read from POST data to get the latest values before they're saved $duplicates_config = null; // Use null to distinguish "no POST data" from "empty config" $has_post_data = false; - - if ( isset( $_POST['duplicate_fields_nonce'] ) && + + if ( isset( $_POST['duplicate_fields_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_POST['duplicate_fields_nonce'] ) ), 'duplicate_fields' ) && isset( $_POST['duplicate_fields_data'] ) && !empty( $_POST['duplicate_fields_data'] ) ) { // Form is being submitted - read from POST to get the latest data $has_post_data = true; + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON data will be sanitized after decoding $decoded_data = json_decode( wp_unslash( $_POST['duplicate_fields_data'] ), true ); if ( is_array( $decoded_data ) ) { $duplicates_config = []; @@ -122,11 +123,7 @@ function dt_options_scripts() { foreach ( $decoded_data as $post_type => $fields ) { if ( in_array( $post_type, $post_types ) && is_array( $fields ) ) { // Handle both empty arrays (user cleared all fields) and non-empty arrays - if ( empty( $fields ) ) { - // Empty array means user wants to revert to defaults - // Don't set the key, so it will use defaults - // Explicitly don't add this post type to config - } else { + if ( !empty( $fields ) ) { // Non-empty array - sanitize and save $sanitized_fields = []; foreach ( $fields as $field_key ) { @@ -141,11 +138,12 @@ function dt_options_scripts() { } // If sanitized_fields is empty (all invalid), don't add to config (use defaults) } + // Empty array means user wants to revert to defaults - don't set the key } } } } - + // If we didn't get data from POST, read from database if ( !$has_post_data ) { // Clear cache to ensure we read fresh data @@ -160,17 +158,17 @@ function dt_options_scripts() { $duplicates_config = []; } } - + $duplicate_fields_data['config'] = $duplicates_config; $duplicate_fields_data['post_types'] = DT_Posts::get_post_types(); - + // Pre-load field settings and defaults for all post types $fields_data = []; $defaults_data = []; foreach ( $duplicate_fields_data['post_types'] as $post_type ) { $field_settings = DT_Posts::get_post_field_settings( $post_type ); $fields_data[$post_type] = $field_settings; - + // Get default fields for this post type $defaults_data[$post_type] = dt_get_duplicate_fields_defaults( $post_type ); } diff --git a/dt-core/admin/menu/tabs/tab-general.php b/dt-core/admin/menu/tabs/tab-general.php index 0067502b44..e081a32ee7 100644 --- a/dt-core/admin/menu/tabs/tab-general.php +++ b/dt-core/admin/menu/tabs/tab-general.php @@ -1024,34 +1024,35 @@ public static function admin_notice( string $notice, string $type ) { * Process duplicate fields settings */ public function process_duplicate_fields() { - if ( isset( $_POST['duplicate_fields_nonce'] ) && + if ( isset( $_POST['duplicate_fields_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_POST['duplicate_fields_nonce'] ) ), 'duplicate_fields' ) ) { - + $site_options = dt_get_option( 'dt_site_options' ); - + // Parse the duplicate fields data from POST if ( isset( $_POST['duplicate_fields_data'] ) && !empty( $_POST['duplicate_fields_data'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON data will be sanitized after decoding $decoded_data = json_decode( wp_unslash( $_POST['duplicate_fields_data'] ), true ); - + if ( is_array( $decoded_data ) ) { $duplicates_config = []; - + // Get all valid post types once (outside the loop) $post_types = DT_Posts::get_post_types(); - + // Process each post type configuration foreach ( $decoded_data as $post_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] ) ) { @@ -1059,7 +1060,7 @@ public function process_duplicate_fields() { } } } - + // 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 @@ -1067,16 +1068,16 @@ public function process_duplicate_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' ); @@ -1093,24 +1094,24 @@ 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; } - + ?>
- +

- + From a4ad1326547f2f90a77976f9ad97a5b3c220f317 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Thu, 5 Feb 2026 16:26:03 +0000 Subject: [PATCH 08/16] Refine duplicate fields configuration text in general settings tab for clarity - Removed redundant mention of the 'name' field in the instructions for configuring duplicate record checks, streamlining the user guidance. --- dt-core/admin/menu/tabs/tab-general.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dt-core/admin/menu/tabs/tab-general.php b/dt-core/admin/menu/tabs/tab-general.php index cee33d4492..e09252c531 100644 --- a/dt-core/admin/menu/tabs/tab-general.php +++ b/dt-core/admin/menu/tabs/tab-general.php @@ -1110,7 +1110,7 @@ public function display_duplicate_fields_settings() { -

+

@@ -1118,9 +1119,9 @@ public function display_duplicate_fields_settings() {
@@ -1122,7 +1122,7 @@ public function display_duplicate_fields_settings() { + ?> @@ -1145,7 +1145,7 @@ public function display_duplicate_fields_settings() {

- +

From 67a800bf906682164c5775a5cea6f033f7024340 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 11 Feb 2026 11:23:34 +0000 Subject: [PATCH 09/16] Refactor Foundation integration in modular-list.js and contacts.js - Simplified conditional checks for Foundation MediaQuery in the collapse_filters function for better readability. - Streamlined the initialization of the Foundation Accordion by removing redundant checks, ensuring it is always executed when filter_accordions are present. - Enhanced the merge duplicate modal opening logic in contacts.js by directly calling the foundation method, improving code clarity and reducing error handling complexity. --- dt-assets/js/modular-list.js | 27 ++++++--------------------- dt-contacts/contacts.js | 32 +------------------------------- 2 files changed, 7 insertions(+), 52 deletions(-) diff --git a/dt-assets/js/modular-list.js b/dt-assets/js/modular-list.js index 68b275a523..a0f0e39a62 100644 --- a/dt-assets/js/modular-list.js +++ b/dt-assets/js/modular-list.js @@ -311,11 +311,7 @@ // Collapse filter tile for mobile view function collapse_filters() { - if ( - window.Foundation && - window.Foundation.MediaQuery && - window.Foundation.MediaQuery.only('small') - ) { + if (window.Foundation.MediaQuery.only('small')) { $('#list-filters .bordered-box').addClass('collapsed'); } else { $('#list-filters .bordered-box').removeClass('collapsed'); @@ -703,17 +699,10 @@ ), ); }); - // Initialize Foundation Accordion if available - if ( - window.Foundation && - window.Foundation.Accordion && - filter_accordions.length - ) { - new window.Foundation.Accordion(filter_accordions, { - slideSpeed: 100, - allowAllClosed: true, - }); - } + new window.Foundation.Accordion(filter_accordions, { + slideSpeed: 100, + allowAllClosed: true, + }); if (selected_tab) { $( `#list-filter-tabs [data-id='${window.SHAREDFUNCTIONS.escapeHTML(selected_tab)}'] a`, @@ -1733,11 +1722,7 @@ search_query, new_filter_labels, ); - if ( - window.Foundation && - window.Foundation.MediaQuery && - window.Foundation.MediaQuery.only('small') - ) { + if (window.Foundation.MediaQuery.only('small')) { $('#tile-filters').addClass('collapsed'); } }); diff --git a/dt-contacts/contacts.js b/dt-contacts/contacts.js index a10dc087a8..8dfd219a2d 100644 --- a/dt-contacts/contacts.js +++ b/dt-contacts/contacts.js @@ -108,38 +108,8 @@ jQuery(document).ready(function ($) { } }); let merge_dupe_edit_modal = $('#merge-dupe-edit-modal'); - $(document).on('click', '#duplicates-detected-notice', function () { - // Ensure modal exists - if (!merge_dupe_edit_modal.length) { - console.error('Merge duplicate modal not found'); - return; - } - - // Check if Foundation is initialized on this element - let foundationInstance = merge_dupe_edit_modal.data('zfPlugin'); - - if (!foundationInstance) { - // Foundation might not be initialized yet, try to initialize it - if (typeof Foundation !== 'undefined' && Foundation.Reveal) { - // Initialize Foundation Reveal on this element - foundationInstance = new Foundation.Reveal(merge_dupe_edit_modal); - } else { - console.error('Foundation library not available'); - return; - } - } - - // Open the modal using the Foundation instance - if (foundationInstance && typeof foundationInstance.open === 'function') { - foundationInstance.open(); - } else if (typeof merge_dupe_edit_modal.foundation === 'function') { - merge_dupe_edit_modal.foundation('open'); - } else { - console.error( - 'Unable to open modal - Foundation not properly initialized', - ); - } + merge_dupe_edit_modal.foundation('open'); }); let possible_duplicates = []; From d7e2b68fcb59885d9c8c1d6c405c355f81018c53 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 11 Feb 2026 12:16:51 +0000 Subject: [PATCH 10/16] Refactor duplicate fields handling in admin scripts - Updated the to remove an unnecessary parameter in the duplicate ID retrieval function for cleaner code. - Initialized as an associative array in to ensure a consistent structure. - Enhanced JavaScript in to ensure is always treated as an array, improving robustness and preventing potential errors. --- dt-contacts/duplicates-merging.php | 2 +- dt-core/admin/admin-enqueue-scripts.php | 11 +++++++++-- dt-core/admin/js/dt-options.js | 10 ++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index d1666a4808..4f08809960 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -88,7 +88,7 @@ public function get_ids_of_non_dismissed_duplicates_endpoint( WP_REST_Request $r $post_id = $params['id'] ?? null; $post_type = $params['post_type'] ?? null; if ( $post_id ){ - return self::ids_of_non_dismissed_duplicates( $post_type, $post_id, false ); + return self::ids_of_non_dismissed_duplicates( $post_type, $post_id ); } else { return new WP_Error( __FUNCTION__, 'Missing field for request', [ 'status' => 400 ] ); } diff --git a/dt-core/admin/admin-enqueue-scripts.php b/dt-core/admin/admin-enqueue-scripts.php index 701d0dcadc..2e957563bc 100644 --- a/dt-core/admin/admin-enqueue-scripts.php +++ b/dt-core/admin/admin-enqueue-scripts.php @@ -103,7 +103,13 @@ function dt_options_scripts() { } // Prepare duplicate fields data for general tab - $duplicate_fields_data = []; + // Initialize as object (associative array) to ensure consistent structure + $duplicate_fields_data = [ + 'config' => [], + 'post_types' => [], + 'fields' => [], + 'defaults' => [], + ]; if ( isset( $_GET['tab'] ) && $_GET['tab'] === 'general' ) { // Check if we're processing a duplicate fields form submission // If so, read from POST data to get the latest values before they're saved @@ -160,7 +166,8 @@ function dt_options_scripts() { } $duplicate_fields_data['config'] = $duplicates_config; - $duplicate_fields_data['post_types'] = DT_Posts::get_post_types(); + // Use array_values() to ensure sequential array keys (0, 1, 2...) for proper JSON encoding as array + $duplicate_fields_data['post_types'] = array_values( DT_Posts::get_post_types() ); // Pre-load field settings and defaults for all post types $fields_data = []; diff --git a/dt-core/admin/js/dt-options.js b/dt-core/admin/js/dt-options.js index 5eab047676..c570a87e6e 100644 --- a/dt-core/admin/js/dt-options.js +++ b/dt-core/admin/js/dt-options.js @@ -1535,12 +1535,18 @@ jQuery(document).ready(function ($) { if ( window.dtOptionAPI && window.dtOptionAPI.duplicate_fields && - Object.keys(window.dtOptionAPI.duplicate_fields).length > 0 && + 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 ) { const duplicateFieldsConfig = window.dtOptionAPI.duplicate_fields.config || {}; - const allPostTypes = window.dtOptionAPI.duplicate_fields.post_types || []; + // 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); const fieldsData = window.dtOptionAPI.duplicate_fields.fields || {}; const defaultFields = window.dtOptionAPI.duplicate_fields.defaults || {}; From a8efb51203c6a5b0bf0568a10471fbdd35cd202d Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 25 Feb 2026 15:43:46 +0000 Subject: [PATCH 11/16] Refactor duplicate fields logic and enhance admin scripts - Updated function to ensure consistent return of default fields for post types, improving clarity and maintainability. - Modified to normalize active tab handling and expose post field settings for use in duplicate fields configuration. - Enhanced JavaScript to safely handle duplicate fields configuration and ensure compatibility with shared post field settings, improving robustness of the admin interface. --- dt-core/admin/admin-enqueue-scripts.php | 30 +++++++++++++++--------- dt-core/admin/js/dt-options.js | 18 ++++++++++---- dt-core/global-functions.php | 31 +++++++++++++------------ 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/dt-core/admin/admin-enqueue-scripts.php b/dt-core/admin/admin-enqueue-scripts.php index 2e957563bc..a988d65746 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,11 +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' ); - // Enqueue web components for dt-multi-select and other components + // 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 ( isset( $_GET['tab'] ) && ( ( $_GET['tab'] === 'people-groups' ) || ( $_GET['tab'] === 'general' ) ) ) { + 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', @@ -102,6 +105,15 @@ 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 // Initialize as object (associative array) to ensure consistent structure $duplicate_fields_data = [ @@ -110,7 +122,7 @@ function dt_options_scripts() { 'fields' => [], 'defaults' => [], ]; - if ( isset( $_GET['tab'] ) && $_GET['tab'] === 'general' ) { + if ( $active_tab === 'general' ) { // Check if we're processing a duplicate fields form submission // If so, read from POST data to get the latest values before they're saved $duplicates_config = null; // Use null to distinguish "no POST data" from "empty config" @@ -169,17 +181,12 @@ function dt_options_scripts() { // Use array_values() to ensure sequential array keys (0, 1, 2...) for proper JSON encoding as array $duplicate_fields_data['post_types'] = array_values( DT_Posts::get_post_types() ); - // Pre-load field settings and defaults for all post types - $fields_data = []; + // Use shared post_field_settings; build defaults per post type $defaults_data = []; foreach ( $duplicate_fields_data['post_types'] as $post_type ) { - $field_settings = DT_Posts::get_post_field_settings( $post_type ); - $fields_data[$post_type] = $field_settings; - - // Get default fields for this post type - $defaults_data[$post_type] = dt_get_duplicate_fields_defaults( $post_type ); + $defaults_data[ $post_type ] = dt_get_duplicate_fields_defaults( $post_type ); } - $duplicate_fields_data['fields'] = $fields_data; + $duplicate_fields_data['fields'] = $post_field_settings; $duplicate_fields_data['defaults'] = $defaults_data; } @@ -194,6 +201,7 @@ 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, ) ); diff --git a/dt-core/admin/js/dt-options.js b/dt-core/admin/js/dt-options.js index c570a87e6e..6667a1b346 100644 --- a/dt-core/admin/js/dt-options.js +++ b/dt-core/admin/js/dt-options.js @@ -1540,14 +1540,24 @@ jQuery(document).ready(function ($) { 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 || {}; + 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); - const fieldsData = window.dtOptionAPI.duplicate_fields.fields || {}; + // 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 @@ -1608,10 +1618,10 @@ jQuery(document).ready(function ($) { 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 + // Check if valid config exists for this post type (use Object.prototype.hasOwnProperty.call to avoid unsafe hasOwnProperty from target object) let selectedFields; if ( - duplicateFieldsConfig.hasOwnProperty(postType) && + Object.prototype.hasOwnProperty.call(duplicateFieldsConfig, postType) && Array.isArray(duplicateFieldsConfig[postType]) && duplicateFieldsConfig[postType].length > 0 ) { diff --git a/dt-core/global-functions.php b/dt-core/global-functions.php index 41dad811f7..f360f5a53b 100755 --- a/dt-core/global-functions.php +++ b/dt-core/global-functions.php @@ -1526,30 +1526,31 @@ function dt_get_global_languages_list(){ * - '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 */ - if ( !function_exists( 'dt_get_duplicate_fields_defaults' ) ) { - function dt_get_duplicate_fields_defaults( $post_type ) { - $default_fields = []; + function dt_get_duplicate_fields_defaults( $post_type ) { + $default_fields = []; - // Name field is always included for all post types - $default_fields[] = 'name'; + // 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 ); + // 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; - } + 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 ); } + + return apply_filters( 'dt_duplicate_fields_defaults', $default_fields, $post_type ); } /** From 9b483afc3cb6c1fd3f09d43bd0aadfc5946c664d Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 25 Feb 2026 16:24:26 +0000 Subject: [PATCH 12/16] Refactor duplicate fields handling and streamline admin scripts - Enhanced the duplicate fields comparison logic to support various field types, including text, communication channels, tags, and multi-select, improving accuracy in duplicate detection. - Simplified the retrieval of duplicate fields configuration by directly reading from the database, ensuring a consistent structure and reducing complexity in form processing. - Removed redundant cache clearing to optimize performance when displaying duplicate fields settings in the admin interface. --- dt-contacts/duplicates-merging.php | 206 +++++++++++++++++------- dt-core/admin/admin-enqueue-scripts.php | 62 +------ dt-core/admin/menu/tabs/tab-general.php | 3 - 3 files changed, 150 insertions(+), 121 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index 4f08809960..31236272ec 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -323,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; @@ -346,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']] = [ @@ -798,71 +851,106 @@ 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"; + $esc_val = esc_sql( $val ); + if ( $op === 'LIKE' ){ + $esc_val = '%' . $esc_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 = 'contacts' 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 ( " . 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"; + $esc_val = esc_sql( $val ); + if ( $op === 'LIKE' ){ + $esc_val = '%' . $esc_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 a988d65746..fd99213d4a 100644 --- a/dt-core/admin/admin-enqueue-scripts.php +++ b/dt-core/admin/admin-enqueue-scripts.php @@ -114,8 +114,7 @@ function dt_options_scripts() { } } - // Prepare duplicate fields data for general tab - // Initialize as object (associative array) to ensure consistent structure + // Prepare duplicate fields data for general tab (read from database only; form processing is in tab-general.php) $duplicate_fields_data = [ 'config' => [], 'post_types' => [], @@ -123,65 +122,10 @@ function dt_options_scripts() { 'defaults' => [], ]; if ( $active_tab === 'general' ) { - // Check if we're processing a duplicate fields form submission - // If so, read from POST data to get the latest values before they're saved - $duplicates_config = null; // Use null to distinguish "no POST data" from "empty config" - $has_post_data = false; - - if ( isset( $_POST['duplicate_fields_nonce'] ) && - wp_verify_nonce( sanitize_key( wp_unslash( $_POST['duplicate_fields_nonce'] ) ), 'duplicate_fields' ) && - isset( $_POST['duplicate_fields_data'] ) && !empty( $_POST['duplicate_fields_data'] ) ) { - // Form is being submitted - read from POST to get the latest data - $has_post_data = true; - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON data will be sanitized after decoding - $decoded_data = json_decode( wp_unslash( $_POST['duplicate_fields_data'] ), true ); - if ( is_array( $decoded_data ) ) { - $duplicates_config = []; - $post_types = DT_Posts::get_post_types(); - foreach ( $decoded_data as $post_type => $fields ) { - if ( in_array( $post_type, $post_types ) && is_array( $fields ) ) { - // Handle both empty arrays (user cleared all fields) and non-empty arrays - if ( !empty( $fields ) ) { - // Non-empty array - sanitize and save - $sanitized_fields = []; - foreach ( $fields as $field_key ) { - $sanitized_field_key = sanitize_key( $field_key ); - $field_settings = DT_Posts::get_post_field_settings( $post_type ); - if ( isset( $field_settings[$sanitized_field_key] ) ) { - $sanitized_fields[] = $sanitized_field_key; - } - } - if ( !empty( $sanitized_fields ) ) { - $duplicates_config[$post_type] = array_unique( $sanitized_fields ); - } - // If sanitized_fields is empty (all invalid), don't add to config (use defaults) - } - // Empty array means user wants to revert to defaults - don't set the key - } - } - } - } - - // If we didn't get data from POST, read from database - if ( !$has_post_data ) { - // Clear cache to ensure we read fresh data - wp_cache_delete( 'dt_site_options', 'options' ); - wp_cache_delete( 'alloptions', 'options' ); - $site_options = dt_get_option( 'dt_site_options' ); - $duplicates_config = $site_options['duplicates'] ?? []; - } else { - // We have POST data - use it (even if empty array, which means use defaults) - // $duplicates_config is already set above - if ( $duplicates_config === null ) { - $duplicates_config = []; - } - } - + $site_options = dt_get_option( 'dt_site_options' ); + $duplicates_config = $site_options['duplicates'] ?? []; $duplicate_fields_data['config'] = $duplicates_config; - // Use array_values() to ensure sequential array keys (0, 1, 2...) for proper JSON encoding as array $duplicate_fields_data['post_types'] = array_values( DT_Posts::get_post_types() ); - - // Use shared post_field_settings; build defaults per post type $defaults_data = []; foreach ( $duplicate_fields_data['post_types'] as $post_type ) { $defaults_data[ $post_type ] = dt_get_duplicate_fields_defaults( $post_type ); diff --git a/dt-core/admin/menu/tabs/tab-general.php b/dt-core/admin/menu/tabs/tab-general.php index e09252c531..75bda3017b 100644 --- a/dt-core/admin/menu/tabs/tab-general.php +++ b/dt-core/admin/menu/tabs/tab-general.php @@ -1088,9 +1088,6 @@ public function process_duplicate_fields() { * Display duplicate fields settings */ public function display_duplicate_fields_settings() { - // Read fresh data (bypass any potential cache) - // This ensures we get the latest saved configuration - wp_cache_delete( 'dt_site_options', 'options' ); $site_options = dt_get_option( 'dt_site_options' ); $duplicates_config = $site_options['duplicates'] ?? []; $post_types = DT_Posts::get_post_types(); From bf37fd9ea942db553660522b224480a119a41180 Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 25 Feb 2026 17:07:32 +0000 Subject: [PATCH 13/16] Refactor SQL query construction in duplicate merging logic - Updated SQL query strings to use consistent single quotes for better readability and maintainability. - Improved the handling of dynamic table names and conditions in the SQL queries for duplicate detection, ensuring clarity and reducing potential errors. --- dt-contacts/duplicates-merging.php | 36 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index 31236272ec..ae5024c1a8 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -895,7 +895,7 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b 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"; + $op = ( strpos( $search_val, '^' ) === 0 ) ? '=' : 'LIKE'; $esc_val = esc_sql( $val ); if ( $op === 'LIKE' ){ $esc_val = '%' . $esc_val . '%'; @@ -903,11 +903,11 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b 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 = 'contacts' AND p.post_title $op '" . $esc_val . "' - 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_type = \'contacts\' AND p.post_title ' . $op . ' \'' . $esc_val . '\' + AND p.ID != ' . (int) $post_id; } } else if ( $field_type === 'communication_channel' ){ $where_parts = []; @@ -919,18 +919,18 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b 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 ( " . implode( ' OR ', $where_parts ) . " ) - AND p.ID != " . (int) $post_id; + $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 ( ' . 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"; + $op = ( strpos( $search_val, '^' ) === 0 ) ? '=' : 'LIKE'; $esc_val = esc_sql( $val ); if ( $op === 'LIKE' ){ $esc_val = '%' . $esc_val . '%'; @@ -938,11 +938,11 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b 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, \'' . 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; } } } From 42d2e85b2634d213446614359b5f02a347fb4fde Mon Sep 17 00:00:00 2001 From: kodinkat Date: Wed, 25 Feb 2026 17:19:39 +0000 Subject: [PATCH 14/16] Enhance SQL query handling in duplicate merging logic - Improved the construction of SQL queries by ensuring consistent escaping of values, particularly for LIKE operations, to enhance security and prevent SQL injection vulnerabilities. - Updated the handling of dynamic post types in SQL queries for better clarity and maintainability. --- dt-contacts/duplicates-merging.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index ae5024c1a8..0e36b48f23 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -896,9 +896,10 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b foreach ( $search_values as $search_val ){ $val = ( strpos( $search_val, '^' ) === 0 ) ? substr( $search_val, 1 ) : $search_val; $op = ( strpos( $search_val, '^' ) === 0 ) ? '=' : 'LIKE'; - $esc_val = esc_sql( $val ); if ( $op === 'LIKE' ){ - $esc_val = '%' . $esc_val . '%'; + $esc_val = '%' . esc_sql( $wpdb->esc_like( $val ) ) . '%'; + } else { + $esc_val = esc_sql( $val ); } if ( !empty( $all_sql ) ){ $all_sql .= ' UNION '; @@ -906,7 +907,7 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b $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 = \'contacts\' AND p.post_title ' . $op . ' \'' . $esc_val . '\' + WHERE p.post_type = \'' . esc_sql( $post_type ) . '\' AND p.post_title ' . $op . ' \'' . $esc_val . '\' AND p.ID != ' . (int) $post_id; } } else if ( $field_type === 'communication_channel' ){ @@ -931,9 +932,10 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b foreach ( $search_values as $search_val ){ $val = ( strpos( $search_val, '^' ) === 0 ) ? substr( $search_val, 1 ) : $search_val; $op = ( strpos( $search_val, '^' ) === 0 ) ? '=' : 'LIKE'; - $esc_val = esc_sql( $val ); if ( $op === 'LIKE' ){ - $esc_val = '%' . $esc_val . '%'; + $esc_val = '%' . esc_sql( $wpdb->esc_like( $val ) ) . '%'; + } else { + $esc_val = esc_sql( $val ); } if ( !empty( $all_sql ) ){ $all_sql .= ' UNION '; From 2d3f77f359917dae6ce34fcff49f9fc0c9b665c2 Mon Sep 17 00:00:00 2001 From: corsac Date: Mon, 2 Mar 2026 12:54:39 +0100 Subject: [PATCH 15/16] add tests, no fuzzy search on tags and multi_select --- dt-contacts/duplicates-merging.php | 8 +- tests/unit-test-duplicate-detection.php | 785 ++++++++++++++++++++++++ 2 files changed, 789 insertions(+), 4 deletions(-) create mode 100644 tests/unit-test-duplicate-detection.php diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index 0e36b48f23..d0be1c7bd2 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -138,24 +138,24 @@ private static function extract_field_values_for_duplicate_search( $post, $field break; case 'tags': - // Array of tag values + // 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[] = $exact_template . $tag_value; + $search_values[] = $tag_value; } } } break; case 'multi_select': - // Array of selected keys + // 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[] = $exact_template . $selected_value; + $search_values[] = $selected_value; } } } diff --git a/tests/unit-test-duplicate-detection.php b/tests/unit-test-duplicate-detection.php new file mode 100644 index 0000000000..664a4209c6 --- /dev/null +++ b/tests/unit-test-duplicate-detection.php @@ -0,0 +1,785 @@ +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'] ); + } +} From f2d44d1d9152bc1f2a3cb802bf750a1a88dad0fe Mon Sep 17 00:00:00 2001 From: corsac Date: Mon, 2 Mar 2026 13:25:32 +0100 Subject: [PATCH 16/16] add group tests --- dt-contacts/duplicates-merging.php | 4 +- tests/unit-test-duplicate-detection.php | 119 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/dt-contacts/duplicates-merging.php b/dt-contacts/duplicates-merging.php index d0be1c7bd2..c00429878d 100644 --- a/dt-contacts/duplicates-merging.php +++ b/dt-contacts/duplicates-merging.php @@ -235,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; } @@ -924,7 +924,7 @@ private static function query_for_duplicate_searches_v2( $post_type, $post_id, b 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 ( ' . implode( ' OR ', $where_parts ) . ' ) + WHERE p.post_type = \'' . esc_sql( $post_type ) . '\' AND ( ' . implode( ' OR ', $where_parts ) . ' ) AND p.ID != ' . (int) $post_id; } } else { diff --git a/tests/unit-test-duplicate-detection.php b/tests/unit-test-duplicate-detection.php index 664a4209c6..8e2e0aa9bd 100644 --- a/tests/unit-test-duplicate-detection.php +++ b/tests/unit-test-duplicate-detection.php @@ -782,4 +782,123 @@ public function test_create_time_duplicate_check_no_match_creates_new() { $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 ); + } }