Jake Vanderwerf
3 days ago ba1e1ccf869b818f7a7a897264dfea05563a7796
inc/managers/UserTermsManager.php
@@ -1,36 +1,63 @@
<?php
namespace JVBase\managers;
use JVBase\registrar\Registrar;
use WP_Post;
use WP_Error;
use Exception;
use WP_Query;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class UserTermsManager
{
    private string $table_name;
    private Cache $cache;
    private string $cacheGroup  = 'user_terms_';
    private int $ttl = DAY_IN_SECONDS; // 1 day default
   protected \wpdb $wpdb;
   protected CustomTable $index;
    public function __construct()
    {
        global $wpdb;
      $this->wpdb = $wpdb;
        $this->table_name = $this->wpdb->prefix . BASE . 'user_term_index';
      $this->defineTables();
        // 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);
    }
   public function defineTables():void
   {
      $userIndex = CustomTable::for('user_term_index');
      $userIndex->setColumns([
         'id'        => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
         'user_id'      => "{$userIndex->getUserIDType()} NOT NULL",
         'term_id'      => "{$userIndex->getTermIDType()} NOT NULL",
         'taxonomy'     => 'varchar(32) NOT NULL',
         'post_count'   => 'int(11) NOT NULL DEFAULT 1',
         'parent_id'    => "{$userIndex->getTermIDType()} NOT NULL DEFAULT 0",
         'last_used'    => 'timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
      ]);
      $userIndex->setKeys([
         ['key' => 'PRIMARY', 'value' => '(`id`)'],
         ['key' => 'UNIQUE', 'value' => '`user_term` (`user_id`, `term_id`, `taxonomy`)'],
         '`user_taxonomy` (`user_id`, `taxonomy`)',
         '`taxonomy` (`taxonomy`)',
         '`user_id` (`user_id`)',
         '`term_id` (`term_id`)',
         '`parent_id` (`parent_id`)'
      ]);
      $base = BASE;
      $userIndex->setConstraints([
         "CONSTRAINT `{$base}user_term_user_fk` FOREIGN KEY (`user_id`)
         REFERENCES `{$userIndex->getUserTable()}` (`ID`) ON DELETE CASCADE",
         "CONSTRAINT `{$base}user_term_term_fk` FOREIGN KEY (`term_id`)
         REFERENCES `{$userIndex->getTermTable()}` (`term_id`) ON DELETE CASCADE"
      ]);
      $userIndex->defineTable();
      $this->index = $userIndex;
   }
    /**
     * @param int $user_id
     * @param string|null $taxonomy
@@ -43,79 +70,8 @@
      $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
    /**
    * Handle term assignment changes
     * @param int $object_id
     * @param array $terms
     * @param array $tt_ids
@@ -135,174 +91,28 @@
        }
        $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);
                }
            }
        }
      $termIDs = array_unique(array_merge($tt_ids, $old_tt_ids));
       if (!empty($termIDs)) {
         foreach ($termIDs as $termID) {
            $term_id = $this->getTermIDFromTTID($termID);
            $this->updateUserTerm($user_id, $term_id, $taxonomy);
         }
      }
    }
    // Handles parent term removal logic
    /**
    * Updates the entry for the user's term relationship
     * @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
    public function updateUserTerm(int $user_id, int $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
    {
      $taxonomy = jvbCheckBase($taxonomy);
        // Ensure the term exists
        $term = get_term($term_id, $taxonomy);
@@ -310,93 +120,53 @@
            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
        ));
      $posts = new WP_Query([
         'post_author'  => $user_id,
         'tax_query'    => [
            [
               'taxonomy'  => $taxonomy,
               'terms'     => $term_id,
            ]
         ],
         'post_status'  => 'publish',
         'posts_per_page'=> -1,
         'fields'    => 'ids'
      ]);
      $userPosts = count($posts->posts);
        // Clear the cache for this user and taxonomy
        $this->clearUserCache($user_id, $taxonomy);
      $result = $this->index->findOrCreate([
         'user_id'   => $user_id,
         'term_id'   => $term_id,
         'taxonomy'  => $taxonomy
      ],[
         'parent_id' => $term->parent,
         'post_count'=> $userPosts
      ]);
        return ($result !== false);
      if ($term->parent > 0) {
         $this->updateUserTerm($user_id, $term->parent, $taxonomy);
      }
        return (bool) $result;
    }
    // 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
    /**
    * 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
        ));
      global $wpdb;
      return $wpdb->get_var($wpdb->prepare(
         "SELECT term_id FROM {$wpdb->term_taxonomy} WHERE term_taxonomy_id = %d",
         $tt_id
      ));
    }
    // Rebuild the user terms index for all users
    /**
    * Rebuild the user terms index for all users
     * @return array
     */
    public function rebuildAllUserIndex():array
@@ -404,16 +174,19 @@
        // Clear existing index
        $this->wpdb->query("TRUNCATE TABLE {$this->table_name}");
      global $wpdb;
        $wpdb->query("TRUNCATE TABLE {$this->index->getFullTableName()}");
        // 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) . '%'
      ));
        $users = get_users([
         'has_published_posts'   => [array_map('jvbCheckBase', Registrar::getRegistered('post'))],
         'fields' => 'ID'
      ]);
      if (empty($users)) {
         return [
            'success'   => true,
            'message'   => 'No users found to update'
         ];
      }
        JVB()->queue()->queueOperation(
            'rebuild_user_term_index',
@@ -422,7 +195,6 @@
                'users' => $users
            ],
            [
                'count'   => count($users),
            'chunk_key' => 'users',
            'chunk_size' => 5,
                'operation_id'      => 'rebuild_user_terms_' . date('Y_m_d')
@@ -435,15 +207,22 @@
        ];
    }
    // Rebuild the index for a specific user
    /**
    * Rebuild the index for a specific user
     * @param int $user_id
     *
     * @return array
     */
    public function rebuildUserIndex(int $user_id):array
    {
      $user = get_userdata($user_id);
      if (!$user) {
         return  [
            'success'   => false,
            'message'   => 'User does not exist'
         ];
      }
        JVB()->queue()->queueOperation(
            'rebuild_user_term_index',
            0,
@@ -452,7 +231,7 @@
            ],
            [
                'count'   => 1,
                'operation_id'      => 'rebuild_user_terms_' . date('Y_m_d')
                'operation_id'      => 'rebuild_user_'.$user_id.'_terms_' . date('Y_m_d')
            ]
        );
@@ -478,63 +257,64 @@
        }
        try {
         $results = [];
            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
                ));
            $this->index->delete(['user_id' => $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) . '%'
            ));
            $posts = new WP_Query([
               'post_status'  => 'publish',
               'post_type'    => array_map('jvbCheckBase', Registrar::getRegistered('post')),
               'posts_per_page'=> -1,
               'fields'    => 'ids'
            ]);
                $processed_terms = 0;
            if (empty($posts->posts)) {
               return [
                  'success'   => true,
                  'result' => [
                     'user_id'   => $user_id,
                     'processed_posts'=> 0,
                     'processed_terms'=> 0,
                  ]
               ];
            }
                foreach ($posts as $post_id) {
                    $post_type = get_post_type($post_id);
                    $taxonomies = get_object_taxonomies($post_type);
            $terms = [];
                    foreach ($taxonomies as $taxonomy) {
                        $terms = wp_get_post_terms($post_id, $taxonomy, ['fields' => 'ids']);
                        $is_hierarchical = is_taxonomy_hierarchical($taxonomy);
            $taxonomies = [];
            $result=[
               'user_id'   => $user_id,
               'posts'     => count($posts->posts)
            ];
            foreach ($posts->posts as $postID) {
               $postType = get_post_type($postID);
               if (!array_key_exists($postType, $taxonomies)) {
                  $taxonomies[$postType] = get_object_taxonomies($postType);
               }
               $tax = $taxonomies[$postType];
               $terms = array_unique(array_merge($terms, wp_get_object_terms($postID, $tax, ['fields' => 'ids'])));
            }
                        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++;
            $result['terms'] = count($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++;
                                    }
                                }
                            }
                        }
                    }
                }
            foreach ($terms as $termID) {
               $taxonomy = get_term($termID)->taxonomy??false;
               if (!$taxonomy) {
                  continue;
               }
               $this->updateUserTerm($user_id, $termID, $taxonomy);
            }
            $results[] = $result;
            }
            return [
                'success' => true,
            'result' => [
               'user_id' => $user_id,
               'processed_posts' => count($posts),
               'processed_terms' => $processed_terms
            ]
            'result' => $results
            ];
        } catch (Exception $e) {
            JVB()->error()->log(
@@ -558,122 +338,29 @@
     *
     * @return array
     */
    public function getUserTerms(int $user_id, string $taxonomy, array $args = []):array
    public function fetchUserTerms(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 = jvbNoBase($taxonomy);
      if (!in_array($taxonomy, Registrar::getRegistered('term'))){
         return [];
      }
        $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
        ";
      $order = array_key_exists('order', $args) ? (!in_array(strtoupper($args['order']), ['ASC', 'DESC']) ? 'DESC' : $args['order']) : 'DESC';
      $orderby = array_key_exists('orderby', $args) ? (!in_array(strtolower($args['orderby']), ['post_count', 'last_used']) ? 'post_count' : $args['orderby']) : 'post_count';
      $limit = array_key_exists('limit', $args) ? absint($args['limit']) : 0;
      $limit = $limit === 0 ? null : $limit;
        $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);
      return $this->index->pluck(
         'term_id',
         [
            'user_id'   => $user_id,
            'taxonomy'  => $taxonomy
         ],
         $orderby,
         $order,
         $limit
      );
    }
}