public static function ajax_term_search() { if (!current_user_can('edit_products')) wp_send_json([]); check_ajax_referer('pmg_fitment_search'); $q = isset($_GET['q']) ? sanitize_text_field(wp_unslash($_GET['q'])) : ''; if ($q === '') wp_send_json([]); // Normalize query spacing $q_norm = trim(preg_replace('/\s+/', ' ', $q)); $tokens = preg_split('/\s+/', $q_norm); $out = []; // Helper to normalize strings for matching (lowercase, remove spaces and non-alnum) $normalize = function($s){ return strtolower(preg_replace('/[^a-z0-9]+/i', '', $s)); }; if (count($tokens) >= 2) { $make_token = array_shift($tokens); // first token = Make $model_tokens = $tokens; // rest = Model tokens $make_need = $normalize($make_token); // Use the last model token to narrow DB search, then filter in PHP for all tokens $model_hint = end($model_tokens); reset($model_tokens); $model_terms = get_terms([ 'taxonomy' => self::TAX, 'hide_empty' => false, 'name__like' => $model_hint, // narrow; we will filter more precisely below 'number' => 200, 'orderby' => 'name', 'order' => 'ASC', ]); if (!is_wp_error($model_terms) && $model_terms) { // Build a normalized needle for the full model tokens (e.g., "cr500" or "cr500r") $model_needles = array_map($normalize, $model_tokens); foreach ($model_terms as $model_term) { $model_name_norm = $normalize($model_term->name); // Ensure all model needles are present in the term name $all_present = true; foreach ($model_needles as $needle) { if ($needle === '') continue; if (strpos($model_name_norm, $needle) === false) { $all_present = false; break; } } if (!$all_present) continue; // Confirm the Make exists somewhere in the ancestor chain (space-insensitive) $anc_ids = get_ancestors($model_term->term_id, self::TAX, 'taxonomy'); if (empty($anc_ids)) continue; $has_make = false; foreach ($anc_ids as $aid) { $p = get_term($aid, self::TAX); if ($p && !is_wp_error($p)) { if (strpos($normalize($p->name), $make_need) !== false) { $has_make = true; break; } } } if (!$has_make) continue; // Pull ALL descendants under the model and filter to 4-digit year names $desc = get_terms([ 'taxonomy' => self::TAX, 'hide_empty' => false, 'child_of' => $model_term->term_id, 'number' => 0, // get all in subtree (guard allows child_of) ]); if (!is_wp_error($desc) && $desc) { foreach ($desc as $term) { if (preg_match('/^\d{4}$/', $term->name)) { $label = self::breadcrumb_label($term); // e.g. "1998 — CR500 → Honda" $out[] = ['id' => (int)$term->term_id, 'text' => esc_html($label)]; } } } // If we found any years for this model, that's enough for this result set if (!empty($out)) break; } } if (!empty($out)) { // Sort years ascending by numeric value, then unique usort($out, function($a, $b){ return intval($a['text']) <=> intval($b['text']); }); $seen = []; $out = array_values(array_filter($out, function($row) use (&$seen){ $key = $row['id']; if (isset($seen[$key])) return false; $seen[$key]=1; return true; })); wp_send_json($out); } } // Fallback: generic name search with breadcrumb labels $terms = get_terms([ 'taxonomy' => self::TAX, 'hide_empty' => false, 'name__like' => $q_norm, 'number' => 20, 'orderby' => 'name', 'order' => 'ASC', ]); if (is_wp_error($terms) || empty($terms)) wp_send_json([]); foreach ($terms as $t) { $label = self::breadcrumb_label($t); $out[] = ['id' => (int)$t->term_id, 'text' => esc_html($label) ]; } wp_send_json($out); }