table_name = $wpdb->prefix . BASE.'taxonomy_relationships'; $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS); // Ensure the table exists // $this->create_table_if_not_exists(); add_action('init', [$this, 'init']); } /** * @return string */ public function getTableName():string { return $this->table_name; } /** * Hook into term and post saves * @return void */ public function init():void { add_action('save_post', [$this, 'updatePostRelationships'], 10, 2); add_action('before_delete_post', [$this, 'updatePostRelationships'], 10, 2); add_action('delete_term', [$this, 'deleteTermRelationships']); add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); } // Update relationships for a post /** * @param int $post_id * * @return void */ public function updatePostRelationships(int $post_id, WP_Post $post):void { $post_type = $post->post_type; if (in_array($post_type, jvbIgnoredPostTypes())) { return; } // Get all taxonomies for this post type $taxonomies = get_object_taxonomies($post_type); if (empty($taxonomies)) { return; } // Get terms for each taxonomy $taxonomy_terms = []; foreach ($taxonomies as $taxonomy) { $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']); if (!is_wp_error($terms) && !empty($terms)) { $taxonomy_terms[$taxonomy] = $terms; // Add parent terms for hierarchical taxonomies if (is_taxonomy_hierarchical($taxonomy)) { foreach ($terms as $term_id) { $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy'); if (!empty($ancestors)) { $taxonomy_terms[$taxonomy] = array_merge($taxonomy_terms[$taxonomy], $ancestors); } } // Remove duplicates $taxonomy_terms[$taxonomy] = array_unique($taxonomy_terms[$taxonomy]); } } } // Create relationships between all taxonomy terms foreach ($taxonomy_terms as $taxonomy => $terms) { foreach ($terms as $term_id) { foreach ($taxonomy_terms as $related_taxonomy => $related_terms) { if ($taxonomy === $related_taxonomy) { continue; // Skip same taxonomy relationships } foreach ($related_terms as $related_term_id) { $this->updateRelationship((int)$term_id, (int)$related_term_id, $taxonomy, $related_taxonomy); } } } } } /** * @param int $term_id * @param int $related_term_id * @param string $taxonomy * @param string $related_taxonomy * * @return bool */ public function updateRelationship(int $term_id, int $related_term_id, string $taxonomy, string $related_taxonomy):bool { global $wpdb; // Make sure both terms exist $term = get_term($term_id, $taxonomy); $related_term = get_term($related_term_id, $related_taxonomy); if (is_wp_error($term) || is_wp_error($related_term) || empty($term) || empty($related_term)) { // One or both terms don't exist, so don't create a relationship return false; } // Ensure term_id and related_term_id are integers $term_id = (int)$term_id; $related_term_id = (int)$related_term_id; // Check if relationship exists $existing = $wpdb->get_row($wpdb->prepare( "SELECT id, post_count FROM {$this->table_name} WHERE term_id = %d AND related_term_id = %d AND taxonomy = %s AND related_taxonomy = %s", $term_id, $related_term_id, $taxonomy, $related_taxonomy )); // Calculate number of shared posts $shared_posts_count = $this->countSharedPosts($term_id, $related_term_id, $taxonomy, $related_taxonomy); // Check if term is parent of related term $is_hierarchical = 0; if ($taxonomy === $related_taxonomy) { $ancestors = get_ancestors($related_term_id, $taxonomy, 'taxonomy'); if (in_array($term_id, $ancestors)) { $is_hierarchical = 1; } } if ($existing) { // Update existing relationship $wpdb->update( $this->table_name, [ 'post_count' => $shared_posts_count, 'is_hierarchical' => $is_hierarchical ], [ 'id' => $existing->id ] ); } else { // Insert new relationship $wpdb->insert( $this->table_name, [ 'term_id' => $term_id, 'related_term_id' => $related_term_id, 'taxonomy' => $taxonomy, 'related_taxonomy' => $related_taxonomy, 'post_count' => $shared_posts_count, 'is_hierarchical' => $is_hierarchical ] ); } return true; } /** * @param int $term_id * @param int $related_term_id * @param string $taxonomy * @param string $related_taxonomy * * @return int */ private function countSharedPosts(int $term_id, int $related_term_id, string $taxonomy, string $related_taxonomy):int { global $wpdb; $term_posts = $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = %s", $term_id, $taxonomy )); $related_term_posts = $wpdb->get_col($wpdb->prepare( "SELECT object_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tt.term_id = %d AND tt.taxonomy = %s", $related_term_id, $related_taxonomy )); return count(array_intersect($term_posts, $related_term_posts)); } // Get related terms based on a term ID and desired taxonomy /** * @param int $term_id * @param string $desired_taxonomy * @param bool $include_hierarchical * * @return array */ public function getRelatedTerms(int $term_id, string $desired_taxonomy, bool $include_hierarchical = true):array { $key = sprintf( '%d_to_%s', $term_id, $desired_taxonomy ); if ($include_hierarchical) { $key.='_hierarchy'; } $cache = $this->cache->get($key); if ($cache) { return $cache; } global $wpdb; $term = get_term($term_id); if (is_wp_error($term) || empty($term)) { return []; } $query = $wpdb->prepare( "SELECT related_term_id FROM {$this->table_name} WHERE term_id = %d AND related_taxonomy = %s", $term_id, $desired_taxonomy ); // Optionally include hierarchical relationships if (!$include_hierarchical) { $query .= " AND is_hierarchical = 0"; } // Order by post count for popularity $query .= " ORDER BY post_count DESC"; $related_term_ids = $wpdb->get_col($query); $this->cache->set($key, $related_term_ids); return $related_term_ids; } // Get all related terms based on multiple source terms (for "any" match) /** * @param array $term_ids * @param string $taxonomy * * @return array */ public function getAnyRelatedTerms(array $term_ids, string $taxonomy):array { if (empty($term_ids)) { return []; } $related_term_ids = []; foreach ($term_ids as $term_id) { $related = $this->getRelatedTerms($term_id, $taxonomy); $related_term_ids = array_merge($related_term_ids, $related); } return array_unique($related_term_ids); } // Get related terms that are common to all source terms (for "all" match) /** * @param array $term_ids * @param string $taxonomy * * @return array */ public function getAllRelatedTerms(array $term_ids, string $taxonomy):array { if (empty($term_ids)) { return []; } $related_sets = []; foreach ($term_ids as $term_id) { $related = $this->getRelatedTerms($term_id, $taxonomy); if (!empty($related)) { $related_sets[] = $related; } } if (count($related_sets) === 1) { return $related_sets[0]; } elseif (count($related_sets) > 1) { // Find intersection of all sets $result = array_intersect(...$related_sets); return array_values($result); } return []; } // Rebuild all relationships (useful for initial setup) /** * @return true */ public function rebuildAllRelationships():bool { $this->cache->flush(); global $wpdb; // Clear existing relationships $wpdb->query("TRUNCATE TABLE {$this->table_name}"); $total_posts = $wpdb->get_var("SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_status = 'publish'"); // Calculate number of batches needed (50 posts per batch) $batch_size = 50; $total_batches = ceil($total_posts / $batch_size); // Queue the operation $queue = JVB()->queue(); $operation_id = 'taxonomy_rebuild_' . uniqid(); $queue->queueOperation( 'taxonomy_relationships', 1, [ 'action' => 'rebuild_all', 'offset' => 0, 'limit' => $batch_size, 'total_posts' => $total_posts, 'total_batches' => $total_batches ], [ 'operation_id' => $operation_id, 'count' => $total_batches, 'priority' => 'normal', ] ); $this->cache->flush(); return true; } /** * @param WP_Error|array $result * @param object $operation * @param array $data * * @return WP_Error|array */ public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array { if ($operation->type !== 'taxonomy_relationships') { return $result; } if (isset($data['action']) && $data['action'] === 'rebuild_all') { // Clear existing relationships global $wpdb; // Get batch of posts to process $offset = isset($data['offset']) ? $data['offset'] : 0; $limit = isset($data['limit']) ? $data['limit'] : 50; // Process in batches $posts = $wpdb->get_col($wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' ORDER BY ID LIMIT %d OFFSET %d", $limit, $offset )); // Process each post foreach ($posts as $post_id) { $this->updatePostRelationships($post_id); } // Get total number of posts $total_posts = $wpdb->get_var("SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_status = 'publish'"); // Return progress information return [ 'success' => true, 'result' => [ 'processed' => count($posts), 'offset' => $offset, 'next_offset' => $offset + $limit, 'total' => $total_posts, 'completed' => ($offset + $limit >= $total_posts) ] ]; } return [ 'success' => false, 'message' => __('Hmmm.', 'jvb') ]; } // Hook this to term deletion /** * @param int $term_id * * @return void */ public function deleteTermRelationships(int $term_id):void { global $wpdb; $wpdb->query($wpdb->prepare( "DELETE FROM {$this->table_name} WHERE term_id = %d OR related_term_id = %d", $term_id, $term_id )); } }