| | |
| | | <?php |
| | | namespace JVBase\managers; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\managers\CacheManager; |
| | | use Exception; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_Query; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | |
| | | |
| | | class TaxonomyRelationships |
| | | { |
| | | private string $table_name; |
| | | protected object $cache; |
| | | |
| | | protected Cache $cache; |
| | | protected CustomTable $relationships; |
| | | |
| | | public function __construct() |
| | | { |
| | | global $wpdb; |
| | | $this->table_name = $wpdb->prefix . BASE.'taxonomy_relationships'; |
| | | $this->cache = CacheManager::for('term_relationship', WEEK_IN_SECONDS); |
| | | $this->defineTables(); |
| | | |
| | | $this->cache = Cache::for('term_relationship', WEEK_IN_SECONDS); |
| | | $this->cache->connect('terms'); |
| | | |
| | | // 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; |
| | | } |
| | | protected function defineTables():void |
| | | { |
| | | $relationships = CustomTable::for('taxonomy_relationships'); |
| | | |
| | | $relationships->setColumns([ |
| | | 'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT', |
| | | 'term_id' => "{$relationships->getTermIDType()} NOT NULL", |
| | | 'related_term_id' => "{$relationships->getTermIDType()} NOT NULL", |
| | | 'taxonomy' => 'varchar(32) NOT NULL', |
| | | 'related_taxonomy' => 'varchar(32) NOT NULL', |
| | | 'post_count' => 'int(11) NOT NULL DEFAULT 0', |
| | | 'is_direct' => 'tinyint(1) NOT NULL DEFAULT 1', |
| | | 'is_hierarchical' => 'tinyint(1) NOT NULL DEFAULT 0', |
| | | 'last_updated' => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' |
| | | ]); |
| | | |
| | | $relationships->setKeys([ |
| | | ['key'=>'PRIMARY', 'value' => '(`id`)'], |
| | | ['key'=>'UNIQUE', 'value' => '`relation` (`term_id`, `related_term_id`, `taxonomy`, `related_taxonomy`)'], |
| | | '`term_id` (`term_id`)', |
| | | '`related_term_id` (`related_term_id`)', |
| | | '`taxonomy` (`taxonomy`)', |
| | | '`related_taxonomy` (`related_taxonomy`)' |
| | | ]); |
| | | $base = BASE; |
| | | $relationships->setConstraints([ |
| | | "CONSTRAINT {$base}tax_rel_term_id FOREIGN KEY (`term_id`) |
| | | REFERENCES `{$relationships->getTermTable()}` (`term_id`) ON DELETE CASCADE", |
| | | "CONSTRAINT `{$base}tax_rel_related_id` FOREIGN KEY (`related_term_id`) |
| | | REFERENCES `{$relationships->getTermTable()}` (`term_id`) ON DELETE CASCADE" |
| | | ]); |
| | | $relationships->defineTable(); |
| | | |
| | | $this->relationships = $relationships; |
| | | } |
| | | |
| | | /** |
| | | * Hook into term and post saves |
| | |
| | | */ |
| | | public function updatePostRelationships(int $post_id, WP_Post $post):void |
| | | { |
| | | // Skip autosaves and revisions |
| | | if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) { |
| | | return; |
| | | } |
| | | $post_type = $post->post_type; |
| | | if (in_array($post_type, jvbIgnoredPostTypes())) { |
| | | return; |
| | | } |
| | | $this->cache->invalidate(); |
| | | // Get all taxonomies for this post type |
| | | $taxonomies = get_object_taxonomies($post_type); |
| | | |
| | |
| | | */ |
| | | 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); |
| | |
| | | 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 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; |
| | | } |
| | | } |
| | | // Calculate number of shared posts |
| | | $shared_posts_count = $this->countSharedPosts($term_id, $related_term_id, $taxonomy, $related_taxonomy); |
| | | $updated = $this->relationships->findOrCreate([ |
| | | 'term_id' => $term_id, |
| | | 'related_term_id' => $related_term_id, |
| | | 'taxonomy' => $taxonomy, |
| | | 'related_taxonomy' => $related_taxonomy |
| | | ],[ |
| | | 'is_hierarchical' => $is_hierarchical, |
| | | 'post_count' => $shared_posts_count |
| | | ]); |
| | | |
| | | // 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; |
| | | return (bool)$updated; |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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 |
| | | $term_posts = $this->cache->remember( |
| | | $this->cache->generateKey(['term' => $term_id, 'taxonomy' => $taxonomy]), |
| | | function () use ($term_id, $taxonomy) { |
| | | global $wpdb; |
| | | return $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 |
| | | )); |
| | | $term_id, |
| | | $taxonomy |
| | | )); |
| | | } |
| | | ); |
| | | |
| | | $related_term_posts = $wpdb->get_col($wpdb->prepare( |
| | | "SELECT object_id FROM {$wpdb->term_relationships} tr |
| | | |
| | | $related_term_posts = $this->cache->remember( |
| | | $this->cache->generateKey(['term' => $related_term_id, 'taxonomy' => $related_taxonomy]), |
| | | function () use($related_term_id, $related_taxonomy) { |
| | | global $wpdb; |
| | | return $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 |
| | | )); |
| | | $related_term_id, |
| | | $related_taxonomy |
| | | )); |
| | | }); |
| | | |
| | | |
| | | return count(array_intersect($term_posts, $related_term_posts)); |
| | | } |
| | |
| | | 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; |
| | | $where = [ |
| | | 'term_id' => $term_id, |
| | | 'related_taxonomy' => $desired_taxonomy |
| | | ]; |
| | | if (!$include_hierarchical) { |
| | | $where['is_hierarchical'] = 0; |
| | | } |
| | | return $this->relationships->pluck('related_term_id', $where, 'post_count', 'DESC'); |
| | | } |
| | | |
| | | // Get all related terms based on multiple source terms (for "any" match) |
| | |
| | | */ |
| | | public function getAnyRelatedTerms(array $term_ids, string $taxonomy):array |
| | | { |
| | | if (empty($term_ids)) { |
| | | return []; |
| | | } |
| | | $term_ids = array_filter(array_map('absint', $term_ids)); |
| | | return array_unique($this->relationships->pluck('related_term_id', ['term_id' => ['IN' => $term_ids], 'related_taxonomy' => $taxonomy], 'post_count', 'DESC')); |
| | | |
| | | $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) |
| | |
| | | */ |
| | | public function rebuildAllRelationships():bool |
| | | { |
| | | $this->cache->invalidate(); |
| | | $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'"); |
| | | $wpdb->query("TRUNCATE TABLE {$this->relationships->getFullTableName()}"); |
| | | |
| | | // Calculate number of batches needed (50 posts per batch) |
| | | $batch_size = 50; |
| | | $total_batches = ceil($total_posts / $batch_size); |
| | | $posts = new WP_Query([ |
| | | 'post_status' => 'publish', |
| | | 'posts_per_page'=> -1, |
| | | 'fields' => 'ids' |
| | | ]); |
| | | if (!$posts->have_posts()) { |
| | | wp_reset_postdata(); |
| | | return true; |
| | | } |
| | | |
| | | $ids = $posts->posts; |
| | | wp_reset_postdata(); |
| | | |
| | | |
| | | // 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 |
| | | 'posts' => $ids, |
| | | ], |
| | | [ |
| | | 'operation_id' => $operation_id, |
| | | 'count' => $total_batches, |
| | | 'priority' => 'normal', |
| | | 'chunk_key' => 'posts', |
| | | 'chunk_size' => 50 |
| | | ] |
| | | ); |
| | | |
| | | $this->cache->invalidate(); |
| | | $this->cache->flush(); |
| | | |
| | | return true; |
| | | } |
| | |
| | | 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') |
| | | ]; |
| | | try { |
| | | foreach ($data['posts'] as $postID) { |
| | | $post = get_post($postID); |
| | | $this->updatePostRelationships($postID, $post); |
| | | } |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | return [ |
| | | 'success' => true |
| | | ]; |
| | | } |
| | | |
| | | // Hook this to term deletion |
| | |
| | | */ |
| | | 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 |
| | | )); |
| | | $this->cache->invalidate(); |
| | | $termIDs = $this->relationships->delete(['term_id' => $term_id]); |
| | | $related = $this->relationships->delete(['related_term_id' => $term_id]); |
| | | } |
| | | } |