<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\meta\Meta;
|
use JVBase\rest\Rest;
|
use JVBase\integrations\Umami;
|
use JVBase\rest\Route;
|
use JVBase\utility\Checker;
|
use JVBase\utility\Features;
|
use WP_Query;
|
use WP_Post;
|
use WP_Term;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
class FeedRoutes extends Rest
|
{
|
protected int $per_page = 36;
|
protected ?Umami $tracker = null;
|
protected ?Checker $checker = null;
|
protected ?array $fields = null;
|
protected ?array $timelineSharedFields = null;
|
protected ?array $timelineUniqueFields = null;
|
|
public function __construct()
|
{
|
$this->cacheName = 'feed';
|
$this->cacheTtl = 86400;
|
parent::__construct();
|
$this->cache
|
->connect('post', true)
|
->connect('taxonomy', true)
|
->connect('user', true);
|
|
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
|
{
|
Route::for('feed')
|
->get([$this, 'handleFeedRequest'])
|
->args([
|
'content' => 'string',
|
'page' => 'integer|default:1|min:1',
|
'taxonomy' => 'string',
|
'match' => 'string|enum:all,any|default:all',
|
'orderby' => 'string',
|
'order' => 'string|enum:ASC,DESC',
|
'date-filter' => 'string',
|
'dateFrom' => 'string',
|
'dateTo' => 'string',
|
'context' => 'string',
|
'source' => 'string',
|
'favourites' => 'boolean',
|
'user' => 'integer',
|
'highlight' => 'string',
|
])
|
->auth('public')
|
->rateLimit(30, 60)
|
->post([$this, 'handleFeedRequest'])
|
->args([
|
'content' => 'string',
|
'page' => 'integer|default:1|min:1',
|
'taxonomy' => 'string',
|
'match' => 'string|enum:all,any|default:all',
|
'orderby' => 'string',
|
'order' => 'string|enum:ASC,DESC',
|
'date-filter' => 'string',
|
'dateFrom' => 'string',
|
'dateTo' => 'string',
|
'context' => 'string',
|
'source' => 'string',
|
'favourites' => 'boolean',
|
'user' => 'integer',
|
'highlight' => 'string',
|
])
|
->auth('public')
|
->rateLimit(30)
|
->register();
|
|
// Feed types endpoint
|
Route::for('feed/types')
|
->get([$this, 'getFeedTypes'])
|
->auth('public')
|
->rateLimit()
|
->register();
|
}
|
|
/**
|
* 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];
|
|
$meta = Meta::forPost($postID);
|
if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) {
|
return $this->formatTimeline($postID, $post);
|
}
|
break;
|
case 'term':
|
|
$meta = Meta::forTerm($postID);
|
$config = JVB_TAXONOMY[$type];
|
break;
|
case 'user':
|
$meta = Meta::forUser($postID);
|
$config = JVB_USER[$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);
|
}
|
|
$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 = Meta::forPost($post->ID);
|
$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 = Meta::forPost($child);
|
$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);
|
}
|
|
/**
|
* @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 = $this->success($cached);
|
return $this->addCacheHeaders($response);
|
}
|
// Fetch and format items
|
$items = $this->fetchFeedItems($args);
|
|
$ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cacheTtl;
|
$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 = $this->success($items);
|
return $this->addCacheHeaders($response);
|
}
|
|
/**
|
* @param array $items 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
|
if (in_array($context['type'], jvbGlobalFeedContentTaxonomies())) {
|
// Global: show posts from any content type with this taxonomy
|
$for_content = JVB_TAXONOMY[$context['type']]['for_content'] ?? [];
|
if (empty($for_content)) {
|
// Fall back to any content that has this taxonomy registered
|
$for_content = array_keys(
|
array_filter(
|
JVB_CONTENT,
|
fn($c) => in_array($context['type'], $c['taxonomies'] ?? [])
|
)
|
);
|
}
|
|
// Convert to full post types with BASE prefix
|
$post_types = array_map(fn($type) => BASE . $type, $for_content);
|
|
// Filter to only show_feed content types
|
$show_feed_types = Features::getTypesWithFeature('show_feed', 'content');
|
$args['post_type'] = array_intersect(
|
$post_types,
|
array_map(fn($type) => BASE . $type, $show_feed_types)
|
);
|
}
|
|
// Add term to tax query
|
$args['tax_query'][] = [
|
'taxonomy' => jvbCheckBase($context['type']),
|
'field' => 'term_id',
|
'terms' => [(int)$context['id']],
|
];
|
break;
|
}
|
|
return $args;
|
}
|
|
/**
|
* @param array $args
|
* @param array $filters
|
*
|
* @return array
|
*/
|
protected function applyFavouritesFilter(array $args, array $data): array
|
{
|
if (empty($data['favourites']) || empty($data['user'])) {
|
return $args;
|
}
|
|
$user_id = (int)$data['user'];
|
$content = jvbNoBase($args['post_type']);
|
|
// Get user's favourites for this content type
|
$fav_key = BASE . 'favourites_' . $content;
|
$favourites = get_user_meta($user_id, $fav_key, true);
|
|
if (empty($favourites)) {
|
// No favourites - return empty result
|
$args['post__in'] = [0]; // Will return no results
|
return $args;
|
}
|
|
$fav_ids = array_filter(array_map('intval', explode(',', $favourites)));
|
|
if (empty($fav_ids)) {
|
$args['post__in'] = [0];
|
return $args;
|
}
|
|
$args['post__in'] = $fav_ids;
|
$args['orderby'] = 'post__in'; // Preserve favourite order
|
|
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 = $this->success($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;
|
});
|
}
|
}
|