<?php
|
namespace JVBase\managers;
|
|
use JVBase\JVB;
|
use JVBase\managers\CacheManager;
|
use WP_Post;
|
use WP_Error;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
class UserTermsManager
|
{
|
private string $table_name;
|
private CacheManager $cache;
|
private string $cacheGroup = 'user_terms_';
|
private int $ttl = DAY_IN_SECONDS; // 1 day default
|
protected \wpdb $wpdb;
|
|
public function __construct()
|
{
|
global $wpdb;
|
$this->wpdb = $wpdb;
|
$this->table_name = $this->wpdb->prefix . BASE . 'user_term_index';
|
// Get cache manager instance
|
$this->cache = new CacheManager($this->cacheGroup, $this->ttl);
|
|
// 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
|
{
|
if ($taxonomy) {
|
// Clear specific taxonomy cache
|
$pattern = "user_{$user_id}_" . str_replace(BASE, '', $taxonomy);
|
$this->cache->clearPattern($pattern);
|
} else {
|
// Clear all user term caches
|
$pattern = "user_{$user_id}_";
|
$this->cache->clearPattern($pattern);
|
}
|
}
|
|
// 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;
|
}
|
|
// 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("
|
SELECT DISTINCT post_author
|
FROM {$this->wpdb->posts}
|
WHERE post_status = 'publish'
|
AND post_type 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,
|
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);
|
$key = $this->cache->generateKey(array_merge(
|
[
|
'user' => $user_id,
|
'taxonomy' => $taxonomy,
|
],
|
$args
|
));
|
if (!$args['skip_cache']) {
|
$cache = $this->cache->get($key);
|
if ($cache) {
|
return $cache;
|
}
|
}
|
|
// 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
|
);
|
$this->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);
|
}
|
|
/**
|
* @param int $user_id
|
*
|
* @return bool
|
*/
|
public function warmCache(int $user_id):bool
|
{
|
// Get all taxonomies
|
$taxonomies = getTaxonomies(['_builtin' => false], 'names');
|
|
foreach ($taxonomies as $taxonomy) {
|
if (str_starts_with($taxonomy, BASE)) {
|
// Pre-cache the most common queries
|
$common_args = [
|
// Most frequently used terms
|
[
|
'orderby' => 'count',
|
'order' => 'DESC',
|
'limit' => 20,
|
'include_parents' => true
|
],
|
// Recently used terms
|
[
|
'orderby' => 'last_used',
|
'order' => 'DESC',
|
'limit' => 20,
|
'include_parents' => true
|
],
|
// Alphabetical list
|
[
|
'orderby' => 'name',
|
'order' => 'ASC',
|
'limit' => 0,
|
'include_parents' => true
|
],
|
// Direct terms only (no parents)
|
[
|
'orderby' => 'count',
|
'order' => 'DESC',
|
'limit' => 0,
|
'only_direct' => true
|
]
|
];
|
|
foreach ($common_args as $args) {
|
// Force skip_cache to ensure we get fresh data
|
$args['skip_cache'] = true;
|
|
// Warm the cache by executing the query
|
$this->getUserTerms($user_id, $taxonomy, $args);
|
}
|
}
|
}
|
return true;
|
}
|
}
|