wpdb = $wpdb; $this->table_name = $this->wpdb->prefix . BASE . 'user_term_index'; // Register hooks add_action('save_post', [$this, 'updatePostUserTerms'], 10, 3); add_action('before_delete_post', [$this, 'removePostUserTerms']); add_action('set_object_terms', [$this, 'handleTermAssignment'], 10, 6); // Add filter for bulk operation handling add_filter(BASE . 'handle_bulk_operation', [ $this, 'processOperation' ], 10, 3); } /** * @param int $user_id * @param string|null $taxonomy * * @return void */ public function clearUserCache(int $user_id, string|null $taxonomy = null):void { $cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true); $cache->flush(); } // Update term usage when a post is saved /** * @param int $post_id * @param WP_Post $post * @param bool $update * * @return void */ public function updatePostUserTerms(int $post_id, WP_Post $post, bool $update):void { // Skip autosaves and revisions if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) { return; } // SAFETY: Skip attachments and other non-content post types if (in_array($post->post_type, jvbIgnoredPostTypes())) { return; } // Skip non-custom post types $post_type = get_post_type($post); if (!str_starts_with($post_type, BASE)) { return; } $user_id = $post->post_author; // Get all taxonomies for this post type $taxonomies = get_object_taxonomies($post_type); foreach ($taxonomies as $taxonomy) { $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']); if (!is_wp_error($terms) && !empty($terms)) { // Add the direct terms foreach ($terms as $term_id) { $this->updateUserTerm($user_id, $term_id, $taxonomy, false); // Check if taxonomy is hierarchical and add parent terms if (is_taxonomy_hierarchical($taxonomy)) { $this->addParentTerms($user_id, $term_id, $taxonomy); } } } $this->clearUserCache($user_id, $taxonomy); } } // Add all parent terms for a given term /** * @param int $user_id * @param int $term_id * @param string $taxonomy * * @return void */ public function addParentTerms(int $user_id, int $term_id, string $taxonomy):void { // Get all ancestors (parent terms) $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy'); if (!empty($ancestors)) { foreach ($ancestors as $ancestor_id) { $this->updateUserTerm($user_id, $ancestor_id, $taxonomy, true); } } } // Handle term assignment changes /** * @param int $object_id * @param array $terms * @param array $tt_ids * @param string $taxonomy * @param bool $append * @param array $old_tt_ids * * @return void */ public function handleTermAssignment(int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids):void { $post = get_post($object_id); // Skip if not a valid post if (!$post || !str_starts_with($post->post_type, BASE)) { return; } $user_id = $post->post_author; $is_hierarchical = is_taxonomy_hierarchical($taxonomy); // Get added terms $added_tt_ids = array_diff($tt_ids, $old_tt_ids); $added_terms = []; if (!empty($added_tt_ids)) { foreach ($added_tt_ids as $tt_id) { $term_id = $this->getTermIDFromTTID($tt_id); if ($term_id) { $added_terms[] = $term_id; } } // Add or increment the new terms foreach ($added_terms as $term_id) { $this->updateUserTerm($user_id, $term_id, $taxonomy, false); // Add parent terms for hierarchical taxonomies if ($is_hierarchical) { $this->addParentTerms($user_id, $term_id, $taxonomy); } } } // Handle removed terms $removed_tt_ids = array_diff($old_tt_ids, $tt_ids); $removed_terms = []; if (!empty($removed_tt_ids)) { foreach ($removed_tt_ids as $tt_id) { $term_id = $this->getTermIDFromTTID($tt_id); if ($term_id) { $removed_terms[] = $term_id; } } // Decrement removed terms foreach ($removed_terms as $term_id) { $this->decreaseUserTerm($user_id, $term_id, $taxonomy, false); // Handle parent terms for hierarchical taxonomies if ($is_hierarchical) { $this->handleParentTermRemoval($user_id, $term_id, $taxonomy); } } } } // Handles parent term removal logic /** * @param int $user_id * @param int $term_id * @param string $taxonomy * * @return void */ public function handleParentTermRemoval(int $user_id, int $term_id, string $taxonomy):void { // Get parent terms $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy'); if (!empty($ancestors)) { foreach ($ancestors as $ancestor_id) { // Check if this parent is still used by other terms $still_needed = $this->isParentNeeded($user_id, $ancestor_id, $taxonomy); if (!$still_needed) { $this->decreaseUserTerm($user_id, $ancestor_id, $taxonomy, true); } } } } // Check if a parent term is still needed by other terms /** * @param int $user_id * @param int $parent_term_id * @param string $taxonomy * * @return bool */ private function isParentNeeded(int $user_id, int $parent_term_id, string $taxonomy):bool { // Get all direct terms the user has $direct_terms = $this->wpdb->get_col($this->wpdb->prepare( "SELECT term_id FROM {$this->table_name} WHERE user_id = %d AND taxonomy = %s AND is_parent = 0 AND post_count > 0", $user_id, $taxonomy )); if (empty($direct_terms)) { return false; } // Check if any of these terms have the parent as an ancestor foreach ($direct_terms as $term_id) { if ($term_id == $parent_term_id) { continue; // Skip the term itself } $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy'); if (in_array($parent_term_id, $ancestors)) { return true; // This parent is needed } } return false; } // Remove all term usage records for a post /** * @param int $post_id * * @return void */ public function removePostUserTerms(int $post_id):void { $post = get_post($post_id); // Skip if not a valid post if (!$post || !str_starts_with($post->post_type, BASE)) { return; } $user_id = $post->post_author; $taxonomies = get_object_taxonomies($post->post_type); foreach ($taxonomies as $taxonomy) { $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']); $is_hierarchical = is_taxonomy_hierarchical($taxonomy); if (!is_wp_error($terms) && !empty($terms)) { foreach ($terms as $term_id) { $this->decreaseUserTerm($user_id, $term_id, $taxonomy, false); // Handle parent terms for hierarchical taxonomies if ($is_hierarchical) { $this->handleParentTermRemoval($user_id, $term_id, $taxonomy); } } } } } // Add or increment a user-term relationship /** * @param int $user_id * @param int $term_id * @param string $taxonomy * @param bool $is_parent * * @return bool */ public function updateUserTerm(int $user_id, int $term_id, string $taxonomy, bool $is_parent = false):bool { // Ensure the term exists $term = get_term($term_id, $taxonomy); if (is_wp_error($term) || empty($term)) { return false; } // Insert or update the record $result = $this->wpdb->query($this->wpdb->prepare( "INSERT INTO {$this->table_name} (user_id, term_id, taxonomy, post_count, is_parent, last_used) VALUES (%d, %d, %s, 1, %d, NOW()) ON DUPLICATE KEY UPDATE post_count = post_count + 1, is_parent = IF(%d = 1, 1, is_parent), last_used = NOW()", $user_id, $term_id, $taxonomy, $is_parent ? 1 : 0, $is_parent ? 1 : 0 )); // Clear the cache for this user and taxonomy $this->clearUserCache($user_id, $taxonomy); return ($result !== false); } // Decrement a user-term relationship /** * @param int $user_id * @param int $term_id * @param string $taxonomy * @param bool $is_parent * * @return bool */ public function decreaseUserTerm(int $user_id, int $term_id, string $taxonomy, bool $is_parent = false):bool { // Update the record, decrementing the counter $this->wpdb->query($this->wpdb->prepare( "UPDATE {$this->table_name} SET post_count = GREATEST(post_count - 1, 0), last_used = NOW() WHERE user_id = %d AND term_id = %d AND taxonomy = %s", $user_id, $term_id, $taxonomy )); // Clean up zero-count records $this->wpdb->query($this->wpdb->prepare( "DELETE FROM {$this->table_name} WHERE user_id = %d AND term_id = %d AND taxonomy = %s AND post_count = 0", $user_id, $term_id, $taxonomy )); // Clear the cache for this user and taxonomy $this->clearUserCache($user_id, $taxonomy); return true; } // Helper function to get term_id from term_taxonomy_id /** * @param int $tt_id * * @return int */ private function getTermIDFromTTID(int $tt_id):int { return $this->wpdb->get_var($this->wpdb->prepare( "SELECT term_id FROM {$this->wpdb->term_taxonomy} WHERE term_taxonomy_id = %d", $tt_id )); } // Rebuild the user terms index for all users /** * @return array */ public function rebuildAllUserIndex():array { // Clear existing index $this->wpdb->query("TRUNCATE TABLE {$this->table_name}"); // Get all users with posts $users = $this->wpdb->get_col($this->wpdb->prepare( "SELECT DISTINCT post_author FROM {$this->wpdb->posts} WHERE post_status = 'publish' AND post_type LIKE %s", $this->wpdb->esc_like(BASE) . '%' )); JVB()->queue()->queueOperation( 'rebuild_user_term_index', 0, [ 'users' => $users ], [ 'count' => count($users), 'chunk_key' => 'users', 'chunk_size' => 5, 'operation_id' => 'rebuild_user_terms_' . date('Y_m_d') ] ); return [ 'success' => true, 'message' => 'Operation Queued for Processing' ]; } // Rebuild the index for a specific user /** * @param int $user_id * * @return array */ public function rebuildUserIndex(int $user_id):array { JVB()->queue()->queueOperation( 'rebuild_user_term_index', 0, [ 'users' => [$user_id] ], [ 'count' => 1, 'operation_id' => 'rebuild_user_terms_' . date('Y_m_d') ] ); return [ 'success' => true, 'message' => 'Operation Queued for Processing' ]; } /** * Handle bulk operations for notifications * * @param WP_Error|array $result Default result * @param object $operation Operation object * @param array $data Current item * * @return WP_Error|array|bool Operation result */ public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array|bool { if ($operation->type !== 'rebuild_user_term_index') { return $result; } try { foreach ($data['users'] as $user_id) { // Clear existing user records $this->wpdb->query($this->wpdb->prepare( "DELETE FROM {$this->table_name} WHERE user_id = %d", $user_id )); // Clear all caches for this user $this->clearUserCache($user_id); // Get all the user's published posts $posts = $this->wpdb->get_col($this->wpdb->prepare( "SELECT ID FROM {$this->wpdb->posts} WHERE post_author = %d AND post_status = 'publish' AND post_type LIKE %s", $user_id, $this->wpdb->esc_like(BASE) . '%' )); $processed_terms = 0; foreach ($posts as $post_id) { $post_type = get_post_type($post_id); $taxonomies = get_object_taxonomies($post_type); foreach ($taxonomies as $taxonomy) { $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']); $is_hierarchical = is_taxonomy_hierarchical($taxonomy); if (!is_wp_error($terms) && !empty($terms)) { foreach ($terms as $term_id) { // Add direct term $this->updateUserTerm($user_id, $term_id, $taxonomy, false); $processed_terms++; // Add parent terms for hierarchical taxonomies if ($is_hierarchical) { $ancestors = get_ancestors($term_id, $taxonomy, 'taxonomy'); foreach ($ancestors as $ancestor_id) { $this->updateUserTerm($user_id, $ancestor_id, $taxonomy, true); $processed_terms++; } } } } } } } return [ 'success' => true, 'result' => [ 'user_id' => $user_id, 'processed_posts' => count($posts), 'processed_terms' => $processed_terms ] ]; } catch (Exception $e) { JVB()->error()->log( '[UserTermsManager]:processOperation', "Exception during operation processing: " . $e->getMessage(), [ 'operation' => $operation->id, ] ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * @param int $user_id * @param string $taxonomy * @param array $args * * @return array */ public function getUserTerms(int $user_id, string $taxonomy, array $args = []):array { // Default arguments $defaults = [ 'orderby' => 'count', // 'count' or 'name' or 'last_used' 'order' => 'DESC', // 'DESC' or 'ASC' 'limit' => 0, // 0 for no limit 'min_count' => 1, // Minimum usage count 'include_parents' => true, // Whether to include parent terms 'only_direct' => false, // If true, only returns directly-used terms 'skip_cache' => false // Whether to skip cache lookup ]; $args = wp_parse_args($args, $defaults); // Skip cache and fetch directly return $this->fetchUserTerms($user_id, $taxonomy, $args); } /** * @param int $user_id * @param string $taxonomy * @param array $args * * @return array */ private function fetchUserTerms(int $user_id, string $taxonomy, array $args):array { $taxonomy = jvbCheckBase($taxonomy); $cache = Cache::for($user_id.'_term_relationships', DAY_IN_SECONDS)->connect('post', true)->connect('taxonomy', true); $key = $cache->generateKey(array_merge( [ 'taxonomy' => $taxonomy, ], $args )); if (!$args['skip_cache']) { $cached = $cache->get($key); if ($cached) { return $cached; } } // Build query $query = " SELECT ut.term_id, ut.post_count, ut.last_used, ut.is_parent, t.name, t.slug FROM {$this->table_name} ut JOIN {$this->wpdb->terms} t ON ut.term_id = t.term_id WHERE ut.user_id = %d AND ut.taxonomy = %s AND ut.post_count >= %d "; $query_args = [ $user_id, $taxonomy, $args['min_count'] ]; // Add parent term filter if needed if ($args['only_direct']) { $query .= " AND ut.is_parent = 0"; } elseif (!$args['include_parents']) { $query .= " AND ut.is_parent = 0"; } // Add ordering switch ($args['orderby']) { case 'name': $query .= " ORDER BY t.name " . ($args['order'] === 'DESC' ? 'DESC' : 'ASC'); break; case 'last_used': $query .= " ORDER BY ut.last_used " . ($args['order'] === 'DESC' ? 'DESC' : 'ASC'); break; case 'count': default: $query .= " ORDER BY ut.post_count " . ($args['order'] === 'DESC' ? 'DESC' : 'ASC'); break; } // Add limit if specified if ($args['limit'] > 0) { $query .= " LIMIT %d"; $query_args[] = $args['limit']; } // Execute query $results = $this->wpdb->get_results( $this->wpdb->prepare($query, $query_args), ARRAY_A ); $cache->set($key, $results); return $results; } // Simple function to get just term IDs for a user /** * @param int $user_id * @param string $taxonomy * @param array $args * * @return array */ public function getUserTermIDs(int $user_id, string $taxonomy, array $args = []):array { // Skip cache $terms = $this->getUserTerms($user_id, $taxonomy, array_merge($args, ['skip_cache' => true])); if (empty($terms)) { return []; } return array_map(function ($term) { return (int)$term['term_id']; }, $terms); } }