cache_name = 'feed'; $this->cache_ttl = 86400; parent::__construct(); $this->cache ->connect('post') ->connect('taxonomy') ->connect('user'); if (JVB_TESTING) { $this->cache->flush(); } } public function init():void { $this->checker = Checker::getInstance(); if (Features::hasIntegration('umami')) { $this->tracker = JVB()->connect('umami'); } } /** * Registers feed routes * @return void */ public function registerRoutes(): void { register_rest_route($this->namespace, '/feed', [ 'methods' => ['GET', 'POST'], 'callback' => [$this, 'handleFeedRequest'], 'permission_callback' => [$this, 'checkPermission'], ]); register_rest_route($this->namespace, 'feed/types', [ 'permission_callback' => [$this, 'checkPermission'], 'methods' => 'GET', 'callback' => [$this, 'getFeedTypes'] ]); } /** * Formats an item * @param int $postID * * @return array */ protected function formatItem(int $postID, string $type = 'post', $skip = false): array { switch ($type) { case 'post': $post = get_post($postID); $type = jvbNoBase($post->post_type); $metaType = 'post'; break; default: $post = get_term($postID, jvbCheckBase($type)); $type = jvbNoBase($type); $metaType = 'term'; break; } if (!$post || is_wp_error($post)) { return []; } return $this->cache->remember( $postID, function() use ($postID, $type, $metaType, $post, $skip) { $config = null; switch ($metaType) { case 'post': $config = JVB_CONTENT[$type]; if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) { return $this->formatTimeline($postID, $post); } break; case 'term': $config = JVB_TAXONOMY[$type]; break; } if (!$config) { return []; } $fields = $config['fields']; //Allow custom filtering for public fields if (array_key_exists('feed', $config) && array_key_exists('fields', $config['feed'])) { $fields = array_filter($fields, function($field) use ($config) { return in_array($field, $config['feed']['fields']); }, ARRAY_FILTER_USE_KEY); } $meta = new MetaManager($postID, $metaType); $values = $meta->getAll(array_keys($fields)); $out = [ 'fields' => $values, ]; //Format Taxonomies $out['taxonomies'] = $this->extractTaxonomies($values, $postID, $type); //Add images $imgIDs = []; $temp = array_filter($fields, function($field) { return in_array($field['type'], [ 'upload', 'image', 'gallery']); }); foreach ($temp as $key => $config) { if (array_key_exists($key, $out['fields']) && $out['fields'][$key] !== '') { $IDs = array_map('absint', explode(',',$out['fields'][$key])); foreach ($IDs as $ID) { $imgIDs[$ID] = jvbImageData($ID); } } } $out['images'] = $imgIDs; $out['id'] = $postID; $out['content'] = $type; $out['icon'] = $config['icon']??jvbDefaultIcon(); if ($this->tracker) { $args = ($metaType === 'post') ? ['owner_id' => $post->post_author] : []; $out['umami_view'] = $this->tracker->trackFeedView($postID, $type, $args); $out['umami_fav'] = $this->tracker->trackFavouriteToggle($postID, $type, false); $out['umami_click'] = $this->tracker->trackClick($postID, $type); } switch ($metaType) { case 'term': $owner = (in_array($type, jvbContentTaxonomies()) ? $meta->getValue('owner') : null); if (!is_null($owner)) { $out['user_id'] = $owner; } $out['url'] = get_term_link($postID, $type); $out['title'] = html_entity_decode($post->name); break; case 'post': $out['date'] = $post->post_date; $out['modified'] = $post->post_modified; $out['user_id'] = (int)$post->post_author; $out['url'] = get_the_permalink($postID); $out['title']= get_the_title($postID); break; } return $out; } ); } protected function initTimelineFields(string $content):void { $content = jvbNoBase($content); if (!Features::forContent($content)->has('is_timeline')){ return; } $config = Features::getConfig($content); $this->fields = $config['fields']; $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) { if (!array_key_exists('for_all', $field) || $field['for_all'] === false){ return true; } return false; })); array_unshift($this->timelineSharedFields, 'post_thumbnail'); array_unshift($this->timelineSharedFields, 'post_title'); $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) { if (array_key_exists('for_all', $field) && $field['for_all'] === true) { return true; } return false; })); } protected function formatTimeline(int $postID, WP_Post $post):array { if (!$this->timelineSharedFields || !$this->timelineUniqueFields){ $this->initTimelineFields($post->post_type); } $item = $this->formatItem($postID, 'post', true); //Step 1: Get the fields that apply to all posts $mainMeta = new MetaManager($post->ID, 'post'); $item['fields'] = $mainMeta->getAll($this->timelineSharedFields); //Step 2: Get the fields for each individual posts $children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish'], 'fields'=> 'ids']); array_unshift($children, $post->ID); $item['taxonomies'] = $this->extractTaxonomies($item['fields'], $postID, jvbNoBase($post->post_type)); $subFields = []; $images = []; foreach ($children as $child) { $meta = new MetaManager($child, 'post'); $f = $meta->getAll($this->timelineUniqueFields); $f = ['id' => $child] + $f; $subFields[] = $f; $item['taxonomies'] = array_merge($item['taxonomies'], $this->extractTaxonomies($f, $postID, jvbNoBase($post->post_type))); $images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']); } $item['number'] = (int)get_post_meta($post->ID,BASE.'number', true); $item['fields']['before'] = get_post_thumbnail_id($children[0]); $item['fields']['after'] = get_post_thumbnail_id($children[array_key_last($children)]); $item['fields']['timeline'] = $subFields; $item['images'] = $item['images'] + $images; return $item; } protected function extractTaxonomies(array $fields, int $postID, string $content):array { $taxonomies = []; foreach ($fields as $key => $value) { if (empty($value)) { continue; } if (!array_key_exists($key, JVB_TAXONOMY)) { continue; } $taxConfig = JVB_TAXONOMY[$key]; if (isset($taxConfig['public']) && $taxConfig['public'] === false) { continue; } $terms = array_map('absint', explode(',', $value)); $terms = array_filter($terms); // Remove 0 values if (empty($terms)) { continue; } foreach($terms as $termID) { $term = get_term($termID, jvbCheckBase($key)); if ($term && !is_wp_error($term)) { $taxonomies[$key][$termID] = $this->formatTaxonomy($term, $postID, $content); } } } return $taxonomies; } protected function formatTaxonomy(WP_Term|int $term, int $postID, string $type) { return $this->cache->remember( $term->term_id, function () use ($term, $postID, $type) { $base = [ 'ID' => $term->term_id, 'title' => html_entity_decode($term->name), 'url' => get_term_link($term->term_id, $term->taxonomy), ]; if ($this->tracker) { $base['umami_click'] =$this->tracker->trackTaxonomyClick($term->term_id, $term->taxonomy, [ 'from' => $type . '_' . $postID ]); } return $base; } ); } protected function getAuthorData(WP_Post $post) { $author = $post->post_author; $userLink = get_user_meta($author, BASE.'link', true); return $this->cache->remember( $userLink, function () use ($userLink, $author) { $label = jvbUserRole($author); if (array_key_exists($label, JVB_USER)) { $label = JVB_USER[$label]['label']; } else { $label = 'Artist'; } return [ 'id' => $userLink, 'label' => $label, 'value' => get_the_title($userLink), 'icon' => 'user', 'url' => get_the_permalink($userLink), ]; } ); } protected function getTaxonomies(int $postID, string $content): array { $taxonomies = jvbTaxonomiesForContent($content); $out = []; foreach ($taxonomies as $tax) { $terms = get_the_terms($postID, $tax); $t = []; if ($terms && !is_wp_error($terms)) { $config = jvbNoBase($tax); $out[] = [ 'icon' => $config, 'title' => JVB_TAXONOMY[$config]['plural'], 'terms' => array_map(function ($term) use ($tax, $postID, $content) { $item = $this->cache->remember( $term->term_id, function() use ($term, $tax, $content, $postID) { return [ 'ID' => $term->term_id, 'title' => html_entity_decode($term->name), 'url' => get_term_link($term->term_id, $tax), ]; } ); $item['umami_click'] = $this->tracker->trackTaxonomyClick($term->term_id, $tax, [ 'from' => $content.'_'.$postID ]); return $item; }, $terms), ]; } } return $out; } protected function buildRequestArgs(WP_REST_Request $request): array { $data = $request->get_params(); $args = [ 'post_type' => (array_key_exists($data['content'], $this->buildFeedTypesConfig())) ? BASE . $data['content'] : BASE . array_key_first(JVB_CONTENT), 'paged' => intval($data['page'] ?? 1), 'posts_per_page' => $this->per_page, ]; if (!empty($data['context'])) { $args = $this->applyContextFilters( $args, [ 'id' => $data['source']??'0', 'type' => $data['context'] ] ); } if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) { $data['taxonomy'] = json_decode($data['taxonomy'], true); } $args = $this->applyContextFilters($args, $data); $args = $this->applyTaxonomyFilters($args, $data); $args = $this->applyOrderFilters($args, $data); $args = $this->applyDateFilters($args, $data); return $this->applyFavouritesFilter($args, $data); } // protected function applyTaxonomyFilters(array $args, array $data): array // { // if (!array_key_exists('taxonomy', $data) || empty($data['taxonomy'])) { // return $args; // } // // $taxonomyFilters = $data['taxonomy']; // // // Validate taxonomies exist and sanitize // $validFilters = []; // foreach ($taxonomyFilters as $taxonomy => $terms) { // if (!taxonomy_exists(jvbCheckBase($taxonomy))) { // continue; // } // // $validFilters[] = [ // 'taxonomy' => jvbCheckBase($taxonomy), // 'field' => 'term_id', // 'terms' => array_map('absint', (array)$terms), // 'operator' => 'IN' // ]; // } // // if (empty($validFilters)) { // return $args; // } // // // Determine relation based on match filter // $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR'; // // $args['tax_query'] = array_merge( // ['relation' => $relation], // $validFilters // ); // // return $args; // } /** * @param WP_REST_Request $request * * @return WP_REST_Response */ public function handleFeedRequest(WP_REST_Request $request): WP_REST_Response { $args = $this->buildRequestArgs($request); $key = $this->cache->generateKey($args); // Check HTTP cache headers first $cache_check = $this->checkHeaders( $request, $key ); if ($cache_check) { return $cache_check; // Returns 304 Not Modified } $cached = $this->cache->get($key); if ($cached) { if ($request->get_param('highlight')) { $highlight = json_decode($request->get_param('highlight'), true); $args['highlight'] = $highlight; } $cached['items'] = $this->processHighlightedItem($cached['items'], $args); $response = new WP_REST_Response($cached); return $this->addCacheHeaders($response); } // Fetch and format items $items = $this->fetchFeedItems($args); $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cache_ttl; $this->cache->set($key, $items, $ttl); if ($request->get_param('highlight')) { $highlight = json_decode($request->get_param('highlight'), true); $args['highlight'] = $highlight; } $items['items'] = $this->processHighlightedItem($items['items'], $args); $response = new WP_REST_Response($items); return $this->addCacheHeaders($response); } /** * Build cache context from query args * Extracts content types and parameters needed for proper cache checking * * @param array $args Built WP_Query arguments * @param WP_REST_Request $request Original request * @return array Cache context with content_types and additional_params */ protected function buildCacheContext(array $args, WP_REST_Request $request): array { // Extract content types from post_type in args $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']]; $content_types = array_map('jvbNoBase', $post_types); $content_types[] = 'feed'; // Always include base feed type // Build additional params for ETag uniqueness $additional_params = [ 'order' => $args['orderby'] ?? 'date', 'direction' => $args['order'] ?? 'DESC', 'page' => $args['paged'] ?? 1, ]; if ($request->get_param('favourites')) { $additional_params['user'] = (int)$request->get_param('user'); } // Include author filter if present (from context or favourites) if (!empty($args['author'])) { $additional_params['author'] = $args['author']; } if (!empty($args['author__in'])) { $additional_params['author__in'] = $args['author__in']; } // Include taxonomy filters if present if (!empty($args['tax_query'])) { $tax_filters = []; foreach ($args['tax_query'] as $key => $query) { if ($key === 'relation' || !is_array($query)) { continue; } $taxonomy = jvbNoBase($query['taxonomy'] ?? ''); if ($taxonomy) { $tax_filters[$taxonomy] = $query['terms'] ?? []; // Also add taxonomy to content_types for timestamp checking $content_types[] = $taxonomy; } } if (!empty($tax_filters)) { $additional_params['taxonomies'] = $tax_filters; } } // Include date filters if present if (!empty($args['date_query'])) { $additional_params['date_filter'] = md5(serialize($args['date_query'])); } // Include meta queries if present if (!empty($args['meta_query'])) { $additional_params['meta_filter'] = md5(serialize($args['meta_query'])); } return [ 'content_types' => array_unique($content_types), 'additional_params' => $additional_params ]; } /** * @param array $args Formatted Args for WP_Query * @param array $data parsed Request Data * * @return array|null */ protected function processHighlightedItem(array $items, array $data): array { if (empty($data['highlight'] ?? null)) { return $items; } // Convert to array if string if (is_string($data['highlight'])) { $data['highlight'] = json_decode($data['highlight'], true); } // Extract key and value $key = array_keys($data['highlight'])[0] ?? false; $value = array_values($data['highlight'])[0] ?? false; error_log('Highlighted item: ' . $key); error_log('Highlighted item: ' . $value); error_log('No Single Content Types: ' . print_r(jvbNoSingleContentTypes(), true)); error_log('Page: ' . print_r($data['paged'], true)); if (in_array($key, jvbNoSingleContentTypes()) && $value && $data['paged'] === 1) { error_log('Formatted Highlighted item: ' . print_r($this->formatItem($value), true)); error_log('Items: ' . print_r($items, true)); array_unshift($items, $this->formatItem($value)); error_log('Items after unshift: ' . print_r($items, true)); } return $items; } /** * @param array $args * @param array $context * * @return array */ protected function applyContextFilters(array $args, array $context): array { if (!isset($context['type'])) { return $args; } switch (true) { case contentIsJVBUserType($context['type']): $args['author'] = (int)get_post_meta($context['id'], BASE . 'link', true); break; case taxIsJVBContentTax($context['type']): $args['post_type'] = is_array($args['post_type']) ? $args['post_type'] : explode(',', $args['post_type']); // Check if filtering global feed content $globalFeedTypes = array_map('jvbCheckBase', array_keys(Features::getTypesWithFeature('show_feed', 'content')) ); if (array_intersect($args['post_type'], $globalFeedTypes)) { $artists = jvbGetContentUsers($context['id']); if (!empty($artists)) { $args['author__in'] = $artists; } } else { $args['tax_query'] = [ 'relation' => 'AND', [ 'taxonomy' => BASE . $context['type'], 'terms' => $context['id'], ] ]; } break; case taxonomy_exists(jvbCheckBase($context['type'])): $args['tax_query'] = [ 'relation' => 'AND', [ 'taxonomy' => BASE . $context['type'], 'terms' => $context['id'], ] ]; break; } return $args; } /** * @param array $args * @param array $filters * * @return array */ protected function applyFavouritesFilter(array $args, array $filters): array { if (!array_key_exists('favourites', $filters)) { return $args; } global $wpdb; // Get post types for the current filter $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']]; $favourites_table = $wpdb->prefix . BASE . 'favourites'; $placeholders = implode(',', array_fill(0, count($post_types), '%s')); $favourited_ids = $wpdb->get_col($wpdb->prepare( "SELECT target_id FROM {$favourites_table} WHERE user_id = %d AND type IN ($placeholders)", array_merge( [get_current_user_id()], $post_types ) )); if (empty($favourited_ids)) { // Force empty results $args['post__in'] = [0]; return $args; } $args['post__in'] = isset($args['post__in']) ? array_intersect($args['post__in'], $favourited_ids) : $favourited_ids; return $args; } /** * @param array $args * * @return array */ protected function fetchFeedItems(array $args): array { $postType = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type']; $slug = jvbNoBase($postType); if (Features::forContent($slug)->has('is_timeline')) { $args['post_parent'] = 0; } if (in_array($slug, Features::getTypesWithFeature('is_content', 'taxonomy'))) { return $this->handleContentTaxonomies($args); } $args['fields'] = 'ids'; // Get post IDs $query = new WP_Query($args); // Batch prefetch related data update_meta_cache('post', $query->posts); update_object_term_cache($query->posts, $args['post_type']); // Format regular items $items = array_map(fn($post) => $this->formatItem($post), $query->posts); wp_reset_postdata(); return [ 'items' => $items, 'has_more' => $query->max_num_pages > $args['paged'], 'total' => $query->found_posts ]; } protected function handleContentTaxonomies(array $args): array { $taxonomy = jvbNoBase($args['post_type']); global $wpdb; $table = $wpdb->prefix . BASE . 'content_' . $taxonomy; // Check if table exists if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") !== $table) { return [ 'items' => [], 'has_more' => false, 'total' => 0 ]; } // Build the query components $queryBuilder = $this->buildCustomTableQuery($args, $table, $taxonomy); // Execute count query first $total = (int)$wpdb->get_var($queryBuilder['count_query']); // Execute main query if we have results $items = []; if ($total > 0) { $results = $wpdb->get_results($queryBuilder['main_query'], ARRAY_A); $items = array_map( fn($ID) => $this->formatItem($ID['term_id'], $taxonomy), $results ); } $page = $args['paged'] ?? 1; $per_page = $args['posts_per_page'] ?? $this->per_page; $has_more = ($page * $per_page) < $total; return [ 'items' => $items, 'has_more' => $has_more, 'total' => $total ]; } /** * Build SQL query components for custom table * @param array $args WP_Query style arguments * @param string $table Table name * @param string $taxonomy Taxonomy type * @return array Query components */ protected function buildCustomTableQuery(array $args, string $table, string $taxonomy): array { global $wpdb; $where_conditions = ['1=1']; $joins = []; $params = []; // Handle search if (!empty($args['s'])) { $search = '%' . $wpdb->esc_like($args['s']) . '%'; $where_conditions[] = "(ct.name LIKE %s OR ct.slug LIKE %s)"; $params[] = $search; $params[] = $search; } // Handle context filters (e.g., filtering shops by style through relationships) if (!empty($args['context_filter'])) { $context_conditions = $this->buildContextConditions($args['context_filter'], $joins, $params, $taxonomy); if (!empty($context_conditions)) { $where_conditions[] = $context_conditions; } } // Handle taxonomy filters (tax_query) if (!empty($args['tax_query'])) { $tax_conditions = $this->buildTaxonomyConditions($args['tax_query'], $joins, $params, $taxonomy); if (!empty($tax_conditions)) { $where_conditions[] = $tax_conditions; } } // Handle meta queries (custom fields in the table) if (!empty($args['meta_query'])) { $meta_conditions = $this->buildMetaConditions($args['meta_query'], $joins, $params, $taxonomy); if (!empty($meta_conditions)) { $where_conditions[] = $meta_conditions; } } // Handle date queries if (!empty($args['date_query'])) { $date_conditions = $this->buildDateConditions($args['date_query'], $params); if (!empty($date_conditions)) { $where_conditions[] = $date_conditions; } } // Handle specific IDs if (!empty($args['include'])) { $placeholders = implode(',', array_fill(0, count($args['include']), '%d')); $where_conditions[] = "ct.term_id IN ({$placeholders})"; $params = array_merge($params, $args['include']); } if (!empty($args['exclude'])) { $placeholders = implode(',', array_fill(0, count($args['exclude']), '%d')); $where_conditions[] = "ct.term_id NOT IN ({$placeholders})"; $params = array_merge($params, $args['exclude']); } // Build ORDER BY $order_by = $this->buildOrderBy($args, $taxonomy); // Build LIMIT $page = $args['paged'] ?? 1; $per_page = $args['posts_per_page'] ?? $this->per_page; $offset = ($page - 1) * $per_page; // Combine everything $joins_sql = !empty($joins) ? implode(' ', $joins) : ''; $where_sql = implode(' AND ', $where_conditions); $base_query = "FROM {$table} ct LEFT JOIN {$wpdb->terms} t ON ct.term_id = t.term_id LEFT JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id {$joins_sql} WHERE {$where_sql}"; $count_query = "SELECT COUNT(DISTINCT ct.term_id) {$base_query}"; $main_query = "SELECT ct.term_id {$base_query} {$order_by} LIMIT %d OFFSET %d"; // Add limit parameters $count_params = $params; $main_params = array_merge($params, [$per_page, $offset]); return [ 'main_query' => $wpdb->prepare($main_query, $main_params), 'count_query' => $wpdb->prepare($count_query, $count_params) ]; } /** * Build context-based filter conditions (e.g., shops filtered by style relationships) * @param array $context_filter Context filter data * @param array &$joins Reference to joins array * @param array &$params Reference to params array * @param string $taxonomy Current taxonomy type * @return string SQL condition */ protected function buildContextConditions(array $context_filter, array &$joins, array &$params, string $taxonomy): string { global $wpdb; $context_type = $context_filter['type'] ?? ''; $context_id = $context_filter['id'] ?? 0; if (empty($context_type) || empty($context_id)) { return ''; } $relationships_table = $wpdb->prefix . BASE . 'taxonomy_relationships'; switch ($context_type) { case 'style': case 'theme': case 'pstyle': // For shops filtered by style/theme through relationships if ($taxonomy === 'shop') { $joins[] = "INNER JOIN {$relationships_table} tr ON ct.term_id = tr.term_id"; $where_condition = "tr.related_term_id = %d AND tr.related_taxonomy = %s"; $params[] = $context_id; $params[] = BASE . $context_type; return $where_condition; } break; case 'city': // Filter by city if (isset($this->getCustomTableFields($taxonomy)['city'])) { $params[] = $context_id; return "ct.city = %d"; } break; } return ''; } /** * Build taxonomy filter conditions for custom table * @param array $tax_query Tax query array * @param array &$joins Reference to joins array * @param array &$params Reference to params array * @param string $taxonomy Current taxonomy type * @return string SQL condition */ protected function buildTaxonomyConditions(array $tax_query, array &$joins, array &$params, string $taxonomy): string { global $wpdb; $conditions = []; $relation = $tax_query['relation'] ?? 'AND'; foreach ($tax_query as $key => $query) { if ($key === 'relation' || !is_array($query)) { continue; } $query_taxonomy = $query['taxonomy'] ?? ''; $terms = (array)($query['terms'] ?? []); $field = $query['field'] ?? 'term_id'; $operator = $query['operator'] ?? 'IN'; if (empty($query_taxonomy) || empty($terms)) { continue; } // Check if this taxonomy field exists in our custom table $custom_fields = $this->getCustomTableFields($taxonomy); $taxonomy_clean = str_replace(BASE, '', $query_taxonomy); if (isset($custom_fields[$taxonomy_clean])) { // Field exists in custom table - direct query $field_column = "ct.{$taxonomy_clean}"; if ($field === 'slug') { // Need to convert slugs to IDs first $term_ids = []; foreach ($terms as $slug) { $term = get_term_by('slug', $slug, $query_taxonomy); if ($term) { $term_ids[] = $term->term_id; } } $terms = $term_ids; } if (!empty($terms)) { $placeholders = implode(',', array_fill(0, count($terms), '%d')); $conditions[] = "{$field_column} {$operator} ({$placeholders})"; $params = array_merge($params, $terms); } } else { // Need to join with term relationships $join_alias = "tr_{$key}"; $joins[] = "LEFT JOIN {$wpdb->term_relationships} {$join_alias} ON ct.term_id = {$join_alias}.object_id"; $joins[] = "LEFT JOIN {$wpdb->term_taxonomy} tt_{$key} ON {$join_alias}.term_taxonomy_id = tt_{$key}.term_taxonomy_id"; if ($field === 'slug') { $joins[] = "LEFT JOIN {$wpdb->terms} t_{$key} ON tt_{$key}.term_id = t_{$key}.term_id"; $field_column = "t_{$key}.slug"; $term_values = $terms; // Use slugs directly } else { $field_column = "tt_{$key}.term_id"; $term_values = array_map('intval', $terms); } $placeholders = implode(',', array_fill(0, count($term_values), $field === 'slug' ? '%s' : '%d')); $taxonomy_condition = "tt_{$key}.taxonomy = %s"; $terms_condition = "{$field_column} {$operator} ({$placeholders})"; $conditions[] = "({$taxonomy_condition} AND {$terms_condition})"; $params[] = $query_taxonomy; $params = array_merge($params, $term_values); } } return !empty($conditions) ? '(' . implode(" {$relation} ", $conditions) . ')' : ''; } /** * Build meta query conditions for custom table fields * @param array $meta_query Meta query array * @param array &$joins Reference to joins array * @param array &$params Reference to params array * @param string $taxonomy Taxonomy type * @return string SQL condition */ protected function buildMetaConditions(array $meta_query, array &$joins, array &$params, string $taxonomy): string { global $wpdb; $conditions = []; $relation = $meta_query['relation'] ?? 'AND'; // Get fields for this taxonomy to know which are in the custom table $custom_fields = $this->getCustomTableFields($taxonomy); foreach ($meta_query as $key => $query) { if ($key === 'relation' || !is_array($query)) { continue; } $meta_key = $query['key'] ?? ''; $meta_value = $query['value'] ?? ''; $compare = $query['compare'] ?? '='; if (empty($meta_key)) { continue; } // Remove BASE prefix if present $clean_key = str_replace(BASE, '', $meta_key); // Check if this field exists in our custom table if (isset($custom_fields[$clean_key])) { // Field is in custom table, query directly $column = "ct.{$clean_key}"; $condition = $this->buildMetaComparison($column, $meta_value, $compare, $params); if ($condition) { $conditions[] = $condition; } } else { // Field is in term meta, need to join $join_alias = "tm_{$key}"; $joins[] = "LEFT JOIN {$wpdb->termmeta} {$join_alias} ON ct.term_id = {$join_alias}.term_id AND {$join_alias}.meta_key = %s"; $params[] = $meta_key; $column = "{$join_alias}.meta_value"; $condition = $this->buildMetaComparison($column, $meta_value, $compare, $params); if ($condition) { $conditions[] = $condition; } } } return !empty($conditions) ? '(' . implode(" {$relation} ", $conditions) . ')' : ''; } /** * Build comparison condition for meta fields * @param string $column Column name * @param mixed $value Value to compare * @param string $compare Comparison operator * @param array &$params Reference to params array * @return string SQL condition */ protected function buildMetaComparison(string $column, $value, string $compare, array &$params): string { switch (strtoupper($compare)) { case 'LIKE': $params[] = '%' . $value . '%'; return "{$column} LIKE %s"; case 'NOT LIKE': $params[] = '%' . $value . '%'; return "{$column} NOT LIKE %s"; case 'IN': if (is_array($value)) { $placeholders = implode(',', array_fill(0, count($value), '%s')); $params = array_merge($params, $value); return "{$column} IN ({$placeholders})"; } break; case 'NOT IN': if (is_array($value)) { $placeholders = implode(',', array_fill(0, count($value), '%s')); $params = array_merge($params, $value); return "{$column} NOT IN ({$placeholders})"; } break; case 'BETWEEN': if (is_array($value) && count($value) === 2) { $params[] = $value[0]; $params[] = $value[1]; return "{$column} BETWEEN %s AND %s"; } break; case '!=': case '<>': $params[] = $value; return "{$column} != %s"; case '>': $params[] = $value; return "{$column} > %s"; case '>=': $params[] = $value; return "{$column} >= %s"; case '<': $params[] = $value; return "{$column} < %s"; case '<=': $params[] = $value; return "{$column} <= %s"; case '=': default: $params[] = $value; return "{$column} = %s"; } return ''; } /** * Build date query conditions * @param array $date_query Date query array * @param array &$params Reference to params array * @return string SQL condition */ protected function buildDateConditions(array $date_query, array &$params): string { $conditions = []; foreach ($date_query as $query) { if (!is_array($query)) continue; $column = $query['column'] ?? 'updated_at'; $year = $query['year'] ?? null; $month = $query['month'] ?? null; $day = $query['day'] ?? null; $after = $query['after'] ?? null; $before = $query['before'] ?? null; if ($year) { $params[] = $year; $conditions[] = "YEAR(ct.{$column}) = %d"; } if ($month) { $params[] = $month; $conditions[] = "MONTH(ct.{$column}) = %d"; } if ($day) { $params[] = $day; $conditions[] = "DAY(ct.{$column}) = %d"; } if ($after) { $params[] = $after; $conditions[] = "ct.{$column} > %s"; } if ($before) { $params[] = $before; $conditions[] = "ct.{$column} < %s"; } } return !empty($conditions) ? '(' . implode(' AND ', $conditions) . ')' : ''; } /** * Build ORDER BY clause * @param array $args Query arguments * @param string $taxonomy Taxonomy type * @return string ORDER BY clause */ protected function buildOrderBy(array $args, string $taxonomy): string { $orderby = $args['orderby'] ?? 'name'; $order = $args['order'] ?? 'ASC'; // Validate order direction if (!in_array($order, ['ASC', 'DESC'])) { $order = 'ASC'; } if (str_contains($orderby, 'RAND')) { return "ORDER BY {$orderby}"; } switch ($orderby) { case 'name': return "ORDER BY ct.name {$order}"; case 'count': return "ORDER BY tt.count {$order}"; case 'term_id': case 'id': return "ORDER BY ct.term_id {$order}"; case 'slug': return "ORDER BY t.slug {$order}"; case 'date': case 'updated': return "ORDER BY ct.updated_at {$order}"; default: // Check if it's a custom field in our table $custom_fields = $this->getCustomTableFields($taxonomy); if (isset($custom_fields[$orderby])) { return "ORDER BY ct.{$orderby} {$order}"; } // Default to name return "ORDER BY ct.name {$order}"; } } /** * Get custom table fields for a taxonomy * @param string $taxonomy Taxonomy type * @return array Field definitions */ protected function getCustomTableFields(string $taxonomy): array { return jvbContentTaxonomiesTableFields($taxonomy)['fields'] ?? []; } /** * Get available feed types (for block editor) * Returns structured data about content types that can be shown in feed */ public function getFeedTypes(WP_REST_Request $request): WP_REST_Response { // Check HTTP cache $cache_check = $this->checkHeaders($request, ['feed_types']); if ($cache_check) { return $cache_check; } $feedTypes = $this->buildFeedTypesConfig(); $response = new WP_REST_Response($feedTypes); return $this->addCacheHeaders($response); } public function getFeedTypesConfig():array { return $this->buildFeedTypesConfig(); } /** * Build feed types configuration from Features */ protected function buildFeedTypesConfig(): array { if (!$this->checker) { $this->checker = Checker::getInstance(); } return $this->cache->remember( 'contentTypes', function () { $config = []; // Get content types with show_feed $contentTypes = Features::getTypesWithFeature('show_feed', 'content'); foreach ($contentTypes as $slug) { $this->cache->tag('content:'.$slug); $contentConfig = JVB_CONTENT[$slug] ?? null; if (!$contentConfig) continue; $config[$slug] = [ 'type' => 'content', 'singular' => $contentConfig['singular'] ?? ucfirst($slug), 'plural' => $contentConfig['plural'] ?? ucfirst($slug) . 's', 'icon' => $slug, 'taxonomies' => $this->checker->getTaxonomiesForContent($slug), ]; } // Get taxonomies with show_feed (content taxonomies) $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy'); foreach ($taxonomies as $slug) { $taxConfig = JVB_TAXONOMY[$slug] ?? null; if (!$taxConfig || !($taxConfig['is_content'] ?? false)) { continue; } $this->cache->tag('taxonomy:'.$slug); $config[$slug] = [ 'type' => 'taxonomy', 'singular' => $taxConfig['singular'] ?? ucfirst($slug), 'plural' => $taxConfig['plural'] ?? ucfirst($slug) . 's', 'icon' => $slug, 'taxonomies' => [], // Content taxonomies don't have sub-taxonomies 'for_content' => $taxConfig['for_content'] ?? [], ]; } return $config; }); } }