cache_name = 'terms'; parent::__construct(); if (JVB_TESTING) { $this->cache->flush(); } $this->per_page = 20; add_action('edited_term', [$this, 'deleteTermPath']); add_action('wp_login', [$this, 'clearUserTaxonomyCache'], 10, 2); } /** * Registers term routes * @return void */ public function registerRoutes():void { register_rest_route($this->namespace, '/terms', [ [ 'methods' => 'GET', 'callback' => [$this, 'handleTermSelectionRequest'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'page' => [ 'required' => true, 'type' => 'integer', 'default' => 1, ], 'taxonomy' => [ 'required' => true, 'type' => 'string' ], 'search' => [ 'required' => false, 'type' => 'string' ], 'parent' => [ 'required' => false, 'type' => 'integer', 'default' => 0 ] ] ], [ 'methods' => 'POST', 'callback' => [$this, 'createTermRequest'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'taxonomy' => [ 'required' => true, 'type' => 'string' ], 'name' => [ 'required' => true, 'type' => 'string' ], 'parent' => [ 'required' => false, 'type' => 'integer', 'default' => 0 ] ] ] ]); //TODO: Just wrap this up to the normal GET request register_rest_route($this->namespace, '/terms/check', [ 'methods' => 'GET', 'callback' => [$this, 'getTermDetails'], 'permission_callback' => [$this, 'checkPermission'], ]); } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleSingleTerm(WP_REST_Request $request):WP_REST_Response { $term_id = (int)$request->get_param('id'); $term = get_term($term_id); if (is_wp_error($term)) { return new WP_REST_Response([ 'success' => false, 'message' => $term ]); } $data = [ 'id' => $term->term_id, 'name' => html_entity_decode($term->name), 'slug' => $term->slug, 'taxonomy' => $term->taxonomy, ]; // Add relationship data if requested if ($request->get_param('include_relationships')) { $relationship_manager = new TaxonomyRelationships(); $related_taxonomies = $request->get_param('related_taxonomies') ?: ['jvb_style', 'jvb_theme', 'jvb_city']; $data['relationships'] = []; foreach ($related_taxonomies as $related_tax) { $relationships = $relationship_manager->getRelatedTerms( $term->term_id, $related_tax ); if (!empty($relationships)) { $data['relationships'][$related_tax] = array_map(function ($rel) { $term = get_term($rel->related_term_id, $rel->related_taxonomy); return [ 'id' => $rel->related_term_id, 'name' => $term ? html_entity_decode($term->name) : 'Unknown', 'count' => $rel->relationship_count ]; }, $relationships); } } } return new WP_REST_Response($data); } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function getTermDetails(WP_REST_Request $request):WP_REST_Response { $data = $request->get_params(); // Collect all taxonomies being queried $taxonomies = array_keys($data); // Check HTTP cache headers $cache_check = $this->checkHeaders($request, $taxonomies); if ($cache_check) { return $cache_check; } $terms = []; foreach ($data as $tax => $IDs) { $args = [ 'taxonomy' => BASE.$tax, 'include' => $IDs ]; $terms[$tax] = $this->formatTerms($args, BASE.$tax); } $response = new WP_REST_Response([ 'items' => $terms, ]); return $this->addCacheHeaders($response); } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleTermSelectionRequest(WP_REST_Request $request):WP_REST_Response { $data = $request->get_params(); $taxonomy = sanitize_text_field($data['taxonomy'])??''; // Check HTTP cache headers $cache_check = $this->checkHeaders($request, $taxonomy); if ($cache_check) { error_log('Header Check failed'); return $cache_check; } // Handle batch request (multiple taxonomies) if (str_contains($taxonomy, ',')) { return $this->handleBatchTermRequest($taxonomy, $data, $request); } $taxonomy = jvbCheckBase($taxonomy); if (array_key_exists('termIDs', $data)) { $args = [ 'taxonomy' => $taxonomy, 'include' => $data['termIDs'], 'hide_empty' => true, ]; $key = $this->cache->generateKey($args); $cached = $this->cache->get($key); if ($cached) { $response = new WP_REST_Response($cached); return $this->addCacheHeaders($response); } $formatted = $this->formatTerms($args, $taxonomy); $response = [ 'items' => $formatted ]; $this->cache->set($key, $response); $response = new WP_REST_Response($response); return $this->addCacheHeaders($response); } if (array_key_exists('content', $data)) { // If content_type is provided, use the specialized endpoint $content_type = $request->get_param('content'); global $feed_types; if (taxIsJVBContentTax($content_type)) { $response = $this->getTermsForContentType($request); return $this->addCacheHeaders($response); } } $taxonomy = BASE.$request->get_param('taxonomy'); $search = $request->get_param('search'); $parent = (int)$data['parent']??0; $page = max(1, (int)($data['page']??1)); $per_page = 25; if (!taxonomy_exists($taxonomy)) { return new WP_REST_Response([ 'items' => [], 'pagination' => [ 'page' => 1, 'per_page' => $per_page, 'total_pages' => 0, 'total_terms' => 0 ], 'has_more' => false ]); } $tax_obj = get_taxonomy($taxonomy); $is_hierarchical = $tax_obj->hierarchical; // If searching, handle differently if (!empty($search)) { error_log('Handling search...'); $response = $this->handleTermSearch($request); return $this->addCacheHeaders($response); } // Get terms for current level with child count $args = [ 'taxonomy' => $taxonomy, 'hide_empty' => true, 'parent' => $parent, 'number' => $per_page, 'orderby'=> 'name', 'offset' => ($page - 1) * $per_page, 'fields' => 'all' ]; if ($request->get_param('main_context') && in_array(jvbCheckBase(json_decode($request->get_param('main_context'), true)['context']), jvbUserTypes())) { $main_context = json_decode($request->get_param('main_context'), true); $userID = get_post_meta($main_context['id'], BASE.'link', true); $manager = new UserTermsManager(); $related = $manager->getUserTermIDs($userID, $taxonomy); if (empty($related)) { $response = new WP_REST_Response([ 'items' => [], 'pagination' => [ 'page' => 1, 'per_page' => $per_page, 'total_pages' => 0, 'total_terms' => 0, ], 'has_more' => false ]); return $this->addCacheHeaders($response); } $args['include'] = $related; } elseif ($request->get_param('main_context')) { $main_context = json_decode($request->get_param('main_context'), true); $thisTaxonomy = str_replace('taxonomy:', '', $main_context['context']); $ID = (int)$main_context['id']; $manager = new TaxonomyRelationships(); $related = $manager->getRelatedTerms($ID, BASE.$request->get_param('taxonomy')); if (empty($related)) { $response = new WP_REST_Response([ 'items' => [], 'pagination' => [ 'page' => 1, 'per_page' => $per_page, 'total_pages' => 0, 'total_terms' => 0 ], 'has_more' => false ]); return $this->addCacheHeaders($response); } $args['tax_query'] = [ 'taxonomy' => $taxonomy, 'terms' => array_filter($related, function ($ID) { return (int)$ID; }) ]; } if ($request->get_param('context')) { $match = $request->get_param('match') ?? 'any'; $context = json_decode($request['context'], true); $relationshipManager = new TaxonomyRelationships(); // Prepare array to collect term IDs that match the context $related_term_ids = []; foreach ($context as $context_taxonomy => $term_ids) { if (!is_array($term_ids) || empty($term_ids)) { continue; } // For each context term, get related terms in the requested taxonomy foreach ($term_ids as $term_id) { $related_terms = $relationshipManager->getRelatedTerms($term_id, $taxonomy); if (!empty($related_terms)) { // Merge with existing term IDs $related_term_ids = array_merge($related_term_ids, $related_terms); } } } // If we have related terms, filter our query to only include them if (!empty($related_term_ids)) { // For 'all' match type, we need to find intersection of all term sets // This logic would need to be expanded based on your exact requirements if ($match === 'all') { // Complex logic for "all" matching goes here // Would need to be implemented based on your exact requirements } // Remove duplicates $related_term_ids = array_unique($related_term_ids); // Include only these terms in our query $args['include'] = $related_term_ids; } else { // No related terms found, return empty result $response = new WP_REST_Response([ 'items' => [], 'pagination' => [ 'page' => 1, 'per_page' => $per_page, 'total_pages' => 0, 'total_terms' => 0 ], 'has_more' => false ]); return $this->addCacheHeaders($response); } } $key = $this->cache->generateKey($args); $cache = $this->cache->get($key); if ($cache) { $response = new WP_REST_Response($cache); return $this->addCacheHeaders($response); } $formatted_terms = $this->formatTerms($args, $taxonomy); // Get total count for pagination $total_terms = wp_count_terms([ 'taxonomy' => $taxonomy, 'hide_empty' => false, 'parent' => $parent ]); // Calculate pagination $total_pages = ceil($total_terms / $per_page); $has_more = $page < $total_pages; $response = [ 'items' => $formatted_terms, 'is_hierarchical' => $is_hierarchical, 'pagination' => [ 'page' => $page, 'per_page' => $per_page, 'total_pages' => $total_pages, 'total_terms' => (int)$total_terms ], 'has_more' => $has_more ]; $this->cache->set($key, $response); $response = new WP_REST_Response($response); return $this->addCacheHeaders($response); } protected function handleBatchTermRequest(string $taxonomy, array $data, WP_REST_Request $request):WP_REST_Response { $taxonomies = array_map('trim', explode(',', $taxonomy)); $all_terms = []; $parent = (int)$data['parent']??0; $page = max(1, (int)($data['page']??1)); $per_page = 25; $mainArgs = [ 'hide_empty'=> false, 'parent' => $parent, 'number' => $per_page, 'orderby' => 'name', 'offset' => ($page -1) * $per_page, ]; foreach ($taxonomies as $taxonomy) { if (!taxonomy_exists(BASE.$taxonomy)) { continue; } $args = $mainArgs; $args['taxonomy'] = BASE.$taxonomy; $all_terms = array_merge($all_terms, $this->formatTerms($args, $taxonomy)); } $response = [ 'items' => $all_terms, 'pagination'=> [ 'page' => $page, 'per_page'=> $per_page ], 'has_more' => true, ]; $response = new WP_REST_Response($response); return $this->addCacheHeaders($response); } /** * @param array $args * @param string $taxonomy * * @return array */ protected function formatTerms(array $args, string $taxonomy): array { return $this->cache->remember( $this->cache->generateKey($args), function() use ($args, $taxonomy) { $terms = get_terms($args); if (is_wp_error($terms)) { return []; } $formatted_terms = []; foreach ($terms as $term) { $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, true); } return $formatted_terms; } ); } /** * Format a single term with caching * * @param object $term WP_Term object * @param string $taxonomy Full taxonomy name * * @return array Formatted term data */ protected function formatSingleTerm(object $term, string $taxonomy): array { $cache_key = "{$term->term_id}_{$taxonomy}"; return $this->cache->remember($cache_key, function() use ($term, $taxonomy) { $data = [ 'id' => $term->term_id, 'name' => html_entity_decode($term->name), 'slug' => $term->slug, 'parent' => $term->parent, 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy), 'taxonomy' => jvbNoBase($term->taxonomy), 'count' => $term->count, ]; $children_args = [ 'taxonomy' => $taxonomy, 'parent' => $term->term_id, 'fields' => 'count', 'hide_empty' => false ]; $count = wp_count_terms($children_args); $data['hasChildren'] = !is_wp_error($count) && $count > 0; return $data; }); } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleTermSearch(WP_REST_Request $request):WP_REST_Response { $taxonomy = BASE.$request->get_param('taxonomy'); $search = $request->get_param('search'); $page = $request->get_param('page') ?? 1; $per_page = $request->get_param('per_page') ?? 20; // When searching, we want to search across all terms regardless of hierarchy $args = [ 'taxonomy' => $taxonomy, 'hide_empty' => true, 'search' => $search, 'search_columns' => ['name', 'slug'], 'fields' => 'all', 'number' => $per_page, 'offset' => ($page - 1) * $per_page, ]; $key = $this->cache->generateKey($args); $cache = $this->cache->get($key); if ($cache) { return new WP_REST_Response($cache); } $terms = get_terms($args); if (is_wp_error($terms)) { return new WP_REST_Response([ 'items' => [], 'pagination' => [ 'page' => 0, 'per_page' => 20, 'total_pages' => 0, 'total_terms' => 0 ], 'has_more' => false ]); } // Get total count for pagination $count_args = array_merge($args, ['fields' => 'count']); $total_terms = wp_count_terms($count_args); $formatted_terms = []; foreach ($terms as $term) { // Search results show path, so includeChildren = false for performance $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, false); } // Calculate pagination info $total_pages = ceil($total_terms / $per_page); $has_more = $page < $total_pages; $response = [ 'items' => $formatted_terms, 'pagination' => [ 'page' => (int)$page, 'per_page' => (int)$per_page, 'total_pages' => $total_pages, 'total_terms' => (int)$total_terms ], 'has_more' => $has_more ]; $this->cache->set($key, $response); return new WP_REST_Response($response); } /** * @param int $termID * @param string $name * @param string $taxonomy * * @return string */ public function getTermPath(int $termID, string $name, string $taxonomy):string { $path = get_term_meta($termID, BASE.'term_path', true); if ($path == '') { // Get ancestor path for each term $ancestors = get_ancestors($termID, $taxonomy, 'taxonomy'); $ancestor_names = []; foreach (array_reverse($ancestors) as $ancestor_id) { $ancestor = get_term($ancestor_id, $taxonomy); if ($ancestor && !is_wp_error($ancestor)) { $ancestor_names[] = $ancestor->name; } } $path = (empty($ancestor_names)) ? $name : implode(' > ', $ancestor_names).' > '.$name; update_term_meta($termID, BASE.'term_path', $path); } return $path; } /** * @param int $termID * * @return void */ public function deleteTermPath($termID) { delete_term_meta($termID, BASE.'term_path'); } /** * @param string $username * @param object $user * * @return void */ public function clearUserTaxonomyCache(string $username, object $user):void { global $wpdb; // Get all transients for this user $like_pattern = $wpdb->esc_like(BASE.'user_' . $user->ID) . '_%'; $transients = $wpdb->get_col($wpdb->prepare(" SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name LIKE %s ", '_transient_' . $like_pattern, '%')); // Delete all found transients foreach ($transients as $transient) { delete_transient(str_replace('_transient_', '', $transient)); } } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function getTermsForContentType(WP_REST_Request $request):WP_REST_Response { $manager = new TaxonomyRelationships(); $content_type = BASE . $request->get_param('content'); $taxonomy = BASE . $request->get_param('taxonomy'); $search = $request->get_param('search'); $page = max(1, intval($request->get_param('page'))); $per_page = $request->get_param('per_page') ?: 20; // Create cache key $cache_key = "terms_for_{$content_type}_{$taxonomy}_" . md5("{$search}_{$page}_{$per_page}"); $cache_key = $this->cache->generateKey([ 'terms_for' => $content_type, 'taxonomy' => $taxonomy, 'search' => $search, 'page' => $page, 'per_page' => $per_page ]); $cache = $this->cache->get($cache_key); // Try cache first if ($cache !== false) { return new WP_REST_Response($cache); } try { global $wpdb; // Starting the query building $query_args = []; $terms_query = " SELECT DISTINCT t.term_id, t.name, t.slug, tt.count, tt.taxonomy, (SELECT COUNT(DISTINCT tm.meta_key) FROM {$wpdb->termmeta} tm WHERE tm.term_id = t.term_id) AS meta_count FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id JOIN {$manager->getTableName()} tr ON ( (tr.primary_taxonomy = %s AND tr.primary_term_id = t.term_id) OR (tr.related_taxonomy = %s AND tr.related_term_id = t.term_id) ) "; $query_args[] = $taxonomy; $query_args[] = $taxonomy; // Add content type filter through posts $query_where = " WHERE tt.taxonomy = %s"; $query_args[] = $taxonomy; // Add search condition if provided if ($search) { $query_where .= " AND (t.name LIKE %s OR t.slug LIKE %s)"; $search_like = '%' . $wpdb->esc_like($search) . '%'; $query_args[] = $search_like; $query_args[] = $search_like; } // Add relationship strength ordering $query_order = " GROUP BY t.term_id"; $query_order .= " ORDER BY COUNT(tr.id) DESC, tt.count DESC"; // Add pagination $query_limit = " LIMIT %d OFFSET %d"; $query_args[] = $per_page; $query_args[] = ($page - 1) * $per_page; // Combine the query $final_query = $query_where . $query_order . $query_limit; // Get terms $terms = $wpdb->get_results( $wpdb->prepare( $terms_query . $final_query, $query_args ) ); // Get total count for pagination $count_query = str_replace($query_limit, '', $query_where); $count_query = "SELECT COUNT(DISTINCT t.term_id) FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id JOIN {$manager->getTableName()} tr ON ( (tr.primary_taxonomy = %s AND tr.primary_term_id = t.term_id) OR (tr.related_taxonomy = %s AND tr.related_term_id = t.term_id) ) " . $count_query; // Remove pagination args array_pop($query_args); array_pop($query_args); $total = $wpdb->get_var( $wpdb->prepare( $count_query, $query_args ) ); // Format terms $formatted_terms = []; $is_hierarchical = is_taxonomy_hierarchical($taxonomy); foreach ($terms as $term) { $formatted = $this->formatSingleTerm($term, $taxonomy, false); // Add relationship strength which is unique to this method $formatted['relationship_strength'] = $term->relationship_count ?? 0; $formatted_terms[] = $formatted; } // Build response $total_pages = ceil($total / $per_page); $results = [ 'items' => $formatted_terms, 'pagination' => [ 'page' => (int)$page, 'per_page' => (int)$per_page, 'total_terms'=> $total, 'total_pages'=> $total_pages ], 'has_more' => $page < $total_pages ]; // Cache results $this->cache->set($cache_key, $results); return new WP_REST_Response($results); } catch (Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ]); } } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleTermsRequest(WP_REST_Request $request):WP_REST_Response { $taxonomy = BASE.$request->get_param('taxonomy'); $search = $request->get_param('search'); $page = max(1, intval($request->get_param('page'))); $per_page = $request->get_param('per_page') ?: $this->per_page; // Create cache key $cache_key = "terms_{$taxonomy}_" . md5("{$search}_{$page}_{$per_page}"); $cache = $this->cache->get($cache_key); if ($cache) { return new WP_REST_Response($cache); } try { // Build query args $args = [ 'taxonomy' => $taxonomy, 'hide_empty' => true, 'orderby' => $search ? 'name' : 'count', 'order' => $search ? 'ASC' : 'DESC', 'number' => $per_page, 'offset' => ($page - 1) * $per_page, 'fields' => 'all' ]; // Add search if provided if ($search) { $args['search'] = $search; } // Get terms $terms = get_terms($args); if (is_wp_error($terms)) { return new WP_REST_Response([ 'items' => [], 'pagination' => [ 'page' => 0, 'per_page' => 20, 'total_pages' => 0, 'total_terms' => 0 ], 'has_more' => 0 ]); } // Check if taxonomy is hierarchical $is_hierarchical = is_taxonomy_hierarchical($taxonomy); // Format terms $formatted_terms = []; foreach ($terms as $term) { $formatted_terms[] = $this->formatSingleTerm($term, $taxonomy, false); } // Get total for pagination $total_args = array_merge($args, ['fields' => 'count', 'number' => '']); $total = wp_count_terms($taxonomy, $total_args); $total_pages = ceil($total / $per_page); $results = [ 'items' => $formatted_terms, 'pagination' => [ 'page' => (int)$page, 'per_page' => (int)$per_page, 'total_pages' => $total_pages, 'total_terms' => (int)$total ], 'has_more' => $page < $total_pages ]; // Cache results $this->cache->set($cache_key, $results); return new WP_REST_Response($results); } catch (Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ]); } } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function createTermRequest(WP_REST_Request $request):WP_REST_Response { $user_id = get_current_user_id(); $taxonomy = $request->get_param('taxonomy'); $name = sanitize_text_field($request->get_param('name')); $parent = (int)$request->get_param('parent') ?: 0; try { // Check if term already exists $existing = term_exists($name, jvbCheckBase($taxonomy), $parent); if ($existing) { $term = get_term($existing['term_id'], jvbCheckBase($taxonomy)); error_log('Existing Term: '.print_r($term, true)); return new WP_REST_Response([ 'success' => false, 'message' => 'Term already exists', 'term' => [ 'id' => $term->term_id, 'name' => html_entity_decode($term->name), 'path' => $this->getTermPath($term->term_id, $term->name, $taxonomy) ] ]); } if (Features::forMembership()->has('term_approval')) { error_log('Term Approval required'); // Get approval routes instance $approval_routes = JVB()->routes('approvals'); // Create approval request $request_id = $approval_routes->createTermApprovalRequest( $user_id, $taxonomy, sanitize_title($name), absint($parent) ); if (!$request_id) { throw new Exception('Failed to create approval request'); } $return = [ 'success' => true, 'message' => 'Term suggestion submitted for approval', 'term' => [ 'id' => 'pending_' . $request_id, 'name' => $name, 'pending' => true, 'request_id' => $request_id ] ]; } else { error_log('Creating new Term: '); $termID = wp_insert_term( $name, jvbCheckBase($taxonomy), [ 'parent' => absint($parent) ] ); error_log('Result: '.print_r($termID, true)); if (is_wp_error($termID)) { throw new Exception('Failed to create new term'); } $return = [ 'success' => true, 'message' => $name.' created successfully', 'term' => [ 'id' => $termID['term_id'], 'name' => $name, 'path' => $this->getTermPath($termID['term_id'], $name, $taxonomy) ] ]; } return new WP_REST_Response($return); } catch (Exception $e) { JVB()->error()->log( 'terms', 'Term creation failed: ' . $e->getMessage(), [ 'user_id' => $user_id, 'taxonomy' => $taxonomy, 'name' => $name ] ); return new WP_REST_Response([ 'success' => false, 'message' => $e->getMessage() ], 500); } } }