From a9b3b28d001941921aa70d37fdc87c758a163a44 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 05 Jun 2026 16:47:03 +0000
Subject: [PATCH] =Some hefty changes to FeedBlock. Transitioning to loading first page in php to save on extra requests. Got a bit to do yet, but I have to work on Northeh for a bit here.

---
 inc/rest/Rest.php |  907 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 907 insertions(+), 0 deletions(-)

diff --git a/inc/rest/Rest.php b/inc/rest/Rest.php
index e69de29..dd6c9cb 100644
--- a/inc/rest/Rest.php
+++ b/inc/rest/Rest.php
@@ -0,0 +1,907 @@
+<?php
+namespace JVBase\rest;
+
+use JVBase\managers\Cache;
+use JVBase\meta\Meta;
+use JVBase\registrar\Registrar;
+use JVBase\base\Site;
+use WP_REST_Request;
+use WP_REST_Response;
+use Exception;
+
+if (!defined('ABSPATH')) {
+	exit;
+}
+
+/**
+ * Base REST Route Manager
+ *
+ * Provides shared utilities for route handlers. Route registration
+ * should use the Route builder class.
+ *
+ * Responsibilities:
+ * - Cache management (headers, invalidation)
+ * - Query building helpers
+ * - Validation utilities
+ * - Audit logging
+ */
+abstract class Rest
+{
+	protected string $namespace = 'jvb/v1';
+	protected ?Cache $cache = null;
+	protected string $cacheName = '';
+	protected int $cacheTtl = 3600;
+	protected static ?string $action;
+
+	public function __construct()
+	{
+		if ($this->cacheName !== '') {
+			$this->cache = Cache::for($this->cacheName, $this->cacheTtl);
+		}
+
+		add_action('rest_api_init', [$this, 'registerRoutes']);
+	}
+
+	/**
+	 * Register routes - implement using Route builder
+	 */
+	abstract public function registerRoutes(): void;
+
+	// =========================================================================
+	// RESPONSE HELPERS
+	// =========================================================================
+
+	protected function success(array $data = [], int $status = 200): WP_REST_Response
+	{
+		return Response::success($data, $status);
+	}
+
+	protected function error(string $message, string $code = 'error', int $status = 400, ?string $field = null): WP_REST_Response
+	{
+		return Response::error($message, $code, $status, $field);
+	}
+
+	protected function validationError(array $errors): WP_REST_Response
+	{
+		return Response::validationError($errors);
+	}
+
+	protected function notFound(string $message = 'Not found'): WP_REST_Response
+	{
+		return Response::notFound($message);
+	}
+
+	protected function forbidden(string $message = 'Forbidden'): WP_REST_Response
+	{
+		return Response::forbidden($message);
+	}
+
+	protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response
+	{
+		return Response::unauthorized($message);
+	}
+
+	protected function queued(string $operationId, string $message = 'Queued for processing'): WP_REST_Response
+	{
+		return Response::queued($operationId, $message);
+	}
+
+	// =========================================================================
+	// CACHE MANAGEMENT
+	// =========================================================================
+
+	protected function checkCache(string $key, $request):WP_REST_Response|false
+	{
+		// Check HTTP cache headers with the specific content type
+		$cache_check = $this->checkHeaders($request, $key);
+		if ($cache_check) {
+			return $cache_check;
+		}
+
+		$cache = $this->cache->get($key);
+		if ($cache) {
+			$response = Response::success($cache);
+			return $this->addCacheHeaders($response);
+		}
+		return false;
+	}
+	/**
+	 * Check request headers for conditional caching (ETag, If-Modified-Since)
+	 */
+	protected function checkHeaders(WP_REST_Request $request, string $cacheKey): ?WP_REST_Response
+	{
+		if (!$this->cache) {
+			return null;
+		}
+
+		$cached = $this->cache->get($cacheKey);
+		if (!$cached) {
+			return null;
+		}
+
+		$etag = $request->get_header('If-None-Match');
+		$cachedEtag = $cached['etag'] ?? null;
+
+		if ($etag && $cachedEtag && $etag === $cachedEtag) {
+			return new WP_REST_Response(null, 304);
+		}
+
+		$ifModifiedSince = $request->get_header('If-Modified-Since');
+		$lastModified = $cached['last_modified'] ?? null;
+
+		if ($ifModifiedSince && $lastModified) {
+			if (strtotime($ifModifiedSince) >= strtotime($lastModified)) {
+				return new WP_REST_Response(null, 304);
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Add cache headers to response
+	 */
+	protected function addCacheHeaders(WP_REST_Response $response, int $maxAge = 300): WP_REST_Response
+	{
+		$response->header('Cache-Control', "private, max-age={$maxAge}");
+		$response->header('Vary', 'Cookie');
+		$response->header('ETag', '"' . md5(serialize($response->get_data())) . '"');
+		$response->header('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');
+
+		return $response;
+	}
+
+	/**
+	 * Store response in cache with metadata
+	 */
+	protected function cacheResponse(string $key, array $data): void
+	{
+		if (!$this->cache) {
+			return;
+		}
+
+		$this->cache->set($key, [
+			'data' => $data,
+			'etag' => '"' . md5(serialize($data)) . '"',
+			'last_modified' => gmdate('D, d M Y H:i:s') . ' GMT',
+		]);
+	}
+
+	// =========================================================================
+	// TIMESTAMP FORMATTING
+	// =========================================================================
+
+	/**
+	 * Convert MySQL datetime to ISO 8601 timestamp
+	 */
+	protected function formatTimestamp(?string $mysqlDatetime): ?string
+	{
+		return Response::formatTimestamp($mysqlDatetime);
+	}
+
+	// =========================================================================
+	// QUERY BUILDING
+	// =========================================================================
+
+	/**
+	 * Apply taxonomy filters to WP_Query args
+	 */
+	protected function applyTaxonomyFilters(array $args, array $data):array
+	{
+		// Handle JSON-encoded taxonomy data
+		if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
+			$data['taxonomy'] = json_decode($data['taxonomy'], true);
+		}
+
+		$taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
+		$taxQuery = [];
+
+		foreach($taxonomies as $taxonomy => $terms) {
+			// Better validation: check if taxonomy actually exists
+			if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
+				continue;
+			}
+
+			$taxQuery[] = [
+				'taxonomy'	=> jvbCheckBase($taxonomy),
+				'field'     => 'term_id',
+				'terms'		=> array_map(
+					'absint',
+					is_array($terms) ? $terms : explode(',', $terms)
+				),
+				'operator'  => 'IN'
+			];
+		}
+
+		if (!empty($taxQuery)) {
+			// Match 'all' = AND, anything else = OR
+			$relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
+
+			$args['tax_query'] = array_merge([
+				'relation'  => $relation,
+			], $taxQuery);
+		}
+
+		// Keep existing author filtering logic
+		$authorQuery = [];
+		foreach (Registrar::getFeatured('can_create', 'user') as $type) {
+			if (array_key_exists($type, $data)) {
+				$artist_ids = array_map(
+					'absint',
+					is_array($data[$type]) ?
+						$data[$type] :
+						explode(',', $data[$type])
+				);
+				$authorQuery = array_merge($authorQuery, $artist_ids);
+			}
+		}
+		if (!empty($authorQuery)) {
+			$args['author__in'] = array_unique($authorQuery);
+		}
+
+		return $args;
+	}
+
+	/**
+	 * Apply order/sort filters to WP_Query args
+	 */
+	protected function applyOrderFilters(array $args, array $data):array
+	{
+		// Check for custom order first
+		$customArgs = $this->applyCustomOrder($args, $data);
+		if ($customArgs !== null) {
+			$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+			$customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+			return $customArgs;
+		}
+
+		//Handle random
+		if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') {
+			$current_seed = floor(time() / 1800);
+			$args['orderby'] = 'RAND(' . $current_seed . ')';
+			unset($args['order']);
+			return $args;
+		}
+
+		if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) {
+			if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) {
+				$args['meta_key'] = BASE . 'latest_date';
+				$args['orderby'] = 'meta_value_num';
+			} else {
+				$args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
+			}
+
+		} else {
+			switch ($data['orderby']) {
+				case 'popularity':
+					$args['meta_key'] = BASE.'upvotes';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'karma':
+					$args['meta_key'] = BASE.'karma';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'unpopularity':
+					$args['meta_key'] = BASE.'downvotes';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'favourites':
+					$args['meta_key'] = BASE.'total_favourites';
+					$args['orderby'] = 'meta_value_num';
+					break;
+				case 'date':
+				default:
+					$args['orderby'] = 'date';
+					break;
+			}
+		}
+		$order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
+		$args['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
+
+		return $args;
+	}
+
+	/**
+	 * Apply custom order if defined in content/taxonomy/user config
+	 *
+	 * @param array $args WP_Query args
+	 * @param array $data Request data
+	 * @return array|null Modified args if custom order found, null otherwise
+	 */
+	protected function applyCustomOrder(array $args, array $data): ?array
+	{
+		$orderby = $data['orderby'] ?? '';
+
+		// Skip if no orderby or it's a standard order
+		if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) {
+			return null;
+		}
+
+		// Determine content type
+		$post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
+		$content = jvbNoBase($post_type);
+
+		// Get config for this content type
+		$registrar = Registrar::getInstance($content);
+		if (!$registrar) {
+			return null;
+		}
+
+		// Check if this orderby is a custom order
+		$customOrders = $registrar->custom_order??[];
+		if (empty($customOrders) || !isset($customOrders[$orderby])) {
+			return null;
+		}
+
+		// Get field definition
+		$fields = $registrar->getFields() ?? [];
+		if (!isset($fields[$orderby])) {
+			return null;
+		}
+
+		$field = $fields[$orderby];
+
+		// Set meta_key
+		$args['meta_key'] = BASE . $orderby;
+
+		// Determine orderby and meta_type based on field type
+		$fieldType = $field['type'] ?? 'text';
+		$subtype = $field['subtype'] ?? '';
+
+		switch ($fieldType) {
+			case 'number':
+				$args['orderby'] = 'meta_value_num';
+				break;
+
+			case 'text':
+				$args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
+				break;
+
+			case 'date':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'DATE';
+				break;
+
+			case 'datetime':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'DATETIME';
+				break;
+
+			case 'true_false':
+			case 'checkbox':
+				$args['orderby'] = 'meta_value';
+				$args['meta_type'] = 'BINARY';
+				break;
+
+			default:
+				$args['orderby'] = 'meta_value';
+		}
+
+		return $args;
+	}
+
+	protected function applyDateFilters(array $args, array $data):array
+	{
+		if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
+			return $args;
+		}
+		if (array_key_exists('dateFrom', $data)) {
+			$dateFrom = strtotime(sanitize_text_field($data['dateFrom']));
+			$dateTo = strtotime(sanitize_text_field($data['dateTo']));
+			if ($dateFrom && $dateTo) {
+				$args['date_query'] = [
+					[
+						'after'     => date('c', $dateFrom),
+						'before'    => date('c', $dateTo),
+						'inclusive' => true,
+					]
+				];
+			}
+		} else {
+			switch ($data['date-filter']) {
+				case 'today':
+					$args['date_query'] = [['after' => '1 day ago']];
+					break;
+				case 'week':
+					$args['date_query'] = [['after' => '1 week ago']];
+					break;
+				case 'month':
+					$args['date_query'] = [['after' => '1 month ago']];
+					break;
+				case 'year':
+					$args['date_query'] = [['after' => '1 year ago']];
+					break;
+			}
+		}
+		return $args;
+	}
+
+	protected function applyCalendarFilters(array $args, array $data):array
+	{
+		$meta_query = [];
+		$today = date('Y-m-d');
+		if (in_array('future', $args['post_status'])) {
+			$meta_query[] = [
+				'key'	=> 'jvb_start_date',
+				'value'	=> $today,
+				'compare'	=> '>=',
+				'type'	=> 'DATE'
+			];
+		}
+		if (in_array('past', $args['post_status'])) {
+			$meta_query[] = [
+				'key' => 'jvb_end_date',
+				'value' => $today,
+				'compare' => '<',
+				'type' => 'DATE'
+			];
+		}
+		if (in_array('recurring', $args['post_status'])) {
+			$meta_query[] = [
+				'key' => 'jvb_is_recurring',
+				'value' => true,
+				'compare' => '='
+			];
+		}
+		if (!empty($meta_query)) {
+			$args['meta_query'] = (array_key_exists('meta_query', $args)) ? array_merge($args['meta_query'], $meta_query) : $meta_query;
+		}
+		return $args;
+
+	}
+
+	/**
+	 * Apply pagination to WP_Query args
+	 */
+	protected function applyPagination(array $args, array $data): array
+	{
+		$args['posts_per_page'] = min(absint($data['per_page'] ?? 20), 100);
+		$args['paged'] = max(absint($data['page'] ?? 1), 1);
+		return $args;
+	}
+
+	// =========================================================================
+	// VALIDATION
+	// =========================================================================
+
+	/**
+	 * Check if user ID matches current logged-in user
+	 */
+	protected function userCheck(int $userId): bool
+	{
+		return $userId === get_current_user_id();
+	}
+
+	/**
+	 * Check if user exists (cached)
+	 */
+	protected function checkUser(int $userId): bool
+	{
+		$cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
+		return $cache->remember($userId, fn() => (bool) get_userdata($userId));
+	}
+
+	/**
+	 * Check if term exists (cached)
+	 */
+	protected function checkTerm(array $args): bool
+	{
+		$termId = $args['term_id'] ?? $args['to_term'] ?? false;
+		$taxonomy = $args['taxonomy'] ?? false;
+
+		if (!$termId || !$taxonomy) {
+			return false;
+		}
+
+		$cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
+		return $cache->remember($termId, fn() => (bool) term_exists($termId, jvbCheckBase($taxonomy)));
+	}
+
+	/**
+	 * Check if user is verified
+	 */
+	protected function isVerifiedUser(int $userId): bool
+	{
+		$cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
+		return $cache->remember($userId, fn() => user_can($userId, 'skip_moderation'));
+	}
+
+	/**
+	 * Sanitize array of IDs
+	 */
+	protected function sanitizeIds(array $ids): array
+	{
+		return array_values(array_filter(array_map('absint', $ids), fn($id) => $id > 0));
+	}
+
+	/**
+	 * Get and validate meta values
+	 */
+	protected function getMetaValues(mixed $value): mixed
+	{
+		$decoded = is_string($value) ? json_decode($value, true) : $value;
+
+		if (!is_array($decoded)) {
+			return $value;
+		}
+
+		return array_map(fn($item) => is_object($item) ? (array) $item : $item, $decoded);
+	}
+
+	/***************************************************************************
+	 * UTILITY
+	***************************************************************************/
+	protected function isTimeline($args, $data):bool
+	{
+		if (!array_key_exists('post_type', $args)) {
+			return false;
+		}
+		$post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
+		$hasTimeline = array_map(function($item) { return jvbCheckBase($item); },Registrar::getFeatured('is_timeline', 'post'));
+		return !empty(array_intersect($post_types, $hasTimeline));
+	}
+	// =========================================================================
+	// SECURITY
+	// =========================================================================
+
+	/**
+	 * Verify Cloudflare Turnstile token
+	 */
+	protected function verifyTurnstile(string $token): bool
+	{
+		if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
+			return true;
+		}
+
+		return !empty($token) && JVB()->connect('cloudflare')->verifyTurnstile($token);
+	}
+
+	/**
+	 * Generate CSRF token for user
+	 */
+	protected function generateCsrfToken(int $userId): string
+	{
+		$token = wp_generate_password(32, false);
+		set_transient(BASE . 'csrf_' . $userId, $token, HOUR_IN_SECONDS);
+		return $token;
+	}
+
+	/**
+	 * Validate CSRF token from request header
+	 */
+	protected function validateCsrfToken(WP_REST_Request $request): bool
+	{
+		if (!is_user_logged_in() || in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) {
+			return true;
+		}
+
+		$userId = get_current_user_id();
+		$token = $request->get_header('X-CSRF-Token');
+		$stored = get_transient(BASE . 'csrf_' . $userId);
+
+		return !empty($stored) && !empty($token) && hash_equals($stored, $token);
+	}
+
+	// =========================================================================
+	// OPERATION LOCKING
+	// =========================================================================
+
+	/**
+	 * Prevent concurrent requests for the same operation
+	 */
+	protected function acquireOperationLock(string $key, int $duration = 5): bool
+	{
+		$lockKey = 'op_lock_' . md5($key);
+
+		if (get_transient($lockKey)) {
+			return false;
+		}
+
+		set_transient($lockKey, true, $duration);
+		return true;
+	}
+
+	/**
+	 * Release operation lock
+	 */
+	protected function releaseOperationLock(string $key): void
+	{
+		delete_transient('op_lock_' . md5($key));
+	}
+
+	// =========================================================================
+	// LOGGING
+	// =========================================================================
+
+	/**
+	 * Log security-relevant events
+	 */
+	protected function auditLog(string $event, array $data = []): void
+	{
+		$context = array_merge($data, [
+			'timestamp' => current_time('mysql'),
+			'user_id' => get_current_user_id() ?: 0,
+			'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
+			'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
+		]);
+
+		try {
+			JVB()->error()->log('security_audit', $event, $context, 'info');
+		} catch (Exception $e) {
+			error_log("Security Audit: {$event} - " . json_encode($context));
+		}
+	}
+
+	/**
+	 * Log errors with proper context
+	 */
+	protected function logError(string $message, array $context = [], string $severity = 'error'): void
+	{
+		try {
+			JVB()->error()->log(static::class, $message, $context, $severity);
+		} catch (Exception $e) {
+			error_log(static::class . " Error: {$message} - " . json_encode($context));
+		}
+	}
+
+	/************************************************************
+	SESSION FINGERPRINT
+	 ************************************************************/
+	/**
+	 * Store session fingerprint for hijacking detection
+	 */
+	protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
+	{
+		if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+			return;
+		}
+
+		$fingerprint = $this->generateSessionFingerprint($request);
+		update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
+		update_user_meta($user_id, BASE . 'session_timestamp', time());
+	}
+	/**
+	 * Generate session fingerprint for hijacking detection
+	 *
+	 * @param WP_REST_Request $request The REST request
+	 * @return string Hashed fingerprint
+	 */
+	protected function generateSessionFingerprint(WP_REST_Request $request): string
+	{
+		return hash('sha256', implode('|', [
+			$request->get_header('User-Agent') ?? '',
+			// Use IP class instead of full IP to allow for mobile network changes
+			$this->getIPClass(
+				$request->get_header('X-Forwarded-For')
+					?: $request->get_header('X-Real-IP')
+					?: $_SERVER['REMOTE_ADDR'] ?? ''
+			)
+		]));
+	}
+
+	/**
+	 * Get IP class (first 3 octets) for session validation
+	 * This allows for minor IP changes (common with mobile networks)
+	 *
+	 * @param string $ip IP address
+	 * @return string First 3 octets
+	 */
+	protected function getIPClass(string $ip): string
+	{
+		$parts = explode('.', $ip);
+		return implode('.', array_slice($parts, 0, 3));
+	}
+
+	/**
+	 * Validate session fingerprint against stored value
+	 *
+	 * @param int $user_id User ID to validate
+	 * @param WP_REST_Request $request Current request
+	 * @return bool True if valid, false if potential hijacking
+	 */
+	protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
+	{
+		// Only enforce if enabled in config
+		if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
+			return true;
+		}
+
+		$stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
+		$current = $this->generateSessionFingerprint($request);
+
+		if (empty($stored)) {
+			// First request - store fingerprint
+			update_user_meta($user_id, BASE . 'session_fingerprint', $current);
+			update_user_meta($user_id, BASE . 'session_timestamp', time());
+			return true;
+		}
+
+		// Compare using timing-safe comparison
+		return hash_equals($stored, $current);
+	}
+
+	/**
+	 * Clear session fingerprint (call on logout)
+	 *
+	 * @param int $user_id User ID
+	 * @return void
+	 */
+	protected function clearSessionFingerprint(int $user_id): void
+	{
+		delete_user_meta($user_id, BASE . 'session_fingerprint');
+		delete_user_meta($user_id, BASE . 'session_timestamp');
+	}
+
+	/*******************************
+	 * META HELPERS
+	*******************************/
+	public function getFieldsOfType(array $fields, string|array $type, Meta $meta, array $subType = []):array
+	{
+		$gotFields = [];
+		if (is_string($type)) {
+			$type = [$type];
+		}
+		foreach ($fields as $field => $value) {
+			//Skip empty values
+			if (empty($value)) {
+				continue;
+			}
+			$config = $meta->config($field);
+			if (in_array($config['type'], ['group', 'repeater', 'tagList'])) {
+				foreach ($config['fields'] as $subfield => $subConfig) {
+					if (is_numeric($subfield) && array_key_exists('name', $subConfig)) {
+						$subfield = $subConfig['name'];
+					}
+					if (is_numeric($subfield)) continue;
+					if (array_key_exists('type', $subConfig) && in_array($subConfig['type'], $type)) {
+						$gotFields[] = $field.':'.$subfield;
+					}
+				}
+			} elseif (in_array($config['type'], $type)) {
+				$gotFields[] = $field;
+			} else if ((!empty($subType) && in_array($config['type'], array_keys($subType)) && in_array($config['subtype'], array_values($subType)))) {
+				$gotFields[] = $field;
+			}
+		}
+		return $gotFields;
+	}
+
+
+	protected function extractImages(array $fields, Meta $meta):array
+	{
+		$images = [];
+		$get = $this->getFieldsOfType($fields, ['upload', 'gallery','image'], $meta);
+		if (!empty($get)) {
+			$baseFields = array_map(function($fieldName) {
+				return (str_contains($fieldName, ':')) ? strtok($fieldName, ':') : $fieldName;
+			}, $get);
+
+			$temp = array_map(
+				function($item) {
+					return explode(':', $item);
+				},
+				array_filter($get, function($fieldName) {
+					return str_contains($fieldName, ':');
+				})
+			);
+			$complex = [];
+			foreach ($temp as $tmp) {
+				$complex[$tmp[0]] = $tmp[1];
+			}
+
+			$fields = array_filter($fields, function ($field) use ($baseFields) {
+				return in_array($field, $baseFields);
+			}, ARRAY_FILTER_USE_KEY);
+
+			foreach ($fields as $fieldName => $value) {
+				//Check if it's a complex field
+				if (array_key_exists($fieldName, $complex)) {
+					$check = $complex[$fieldName];
+					foreach ($value as $row) {
+						foreach ($row as $fName => $fValue) {
+							if ($fName === $check && !empty($fValue)) {
+								$images = $this->addImages($fValue, $images);
+							}
+						}
+					}
+				} else {
+					$images = $this->addImages($value, $images);
+				}
+			}
+		}
+		return $images;
+	}
+	public function addImages(string $imgs, array $images):array
+	{
+		$temp = explode(',', $imgs);
+		foreach ($temp as $img) {
+			if (is_numeric($img) && !array_key_exists($img, $images) && $img > 0) {
+				$images[$img] = jvbImageData((int)$img);
+			}
+		}
+		return $images;
+	}
+
+	protected function extractTerms(array $fields, Meta $meta):array
+	{
+		$terms = [];
+		$get = $this->getFieldsOfType($fields, ['taxonomy'], $meta, ['selector' => 'taxonomy']);
+		if (!empty($get)) {
+			$baseFields = array_map(function($fieldName) {
+				return (str_contains($fieldName, ':')) ? strtok($fieldName, ':') : $fieldName;
+			}, $get);
+
+			$complex = array_map(
+				function($item) {
+					return explode(':', $item);
+				},
+				array_filter($get, function($fieldName) {
+					return str_contains($fieldName, ':');
+				})
+			);
+
+			$fields = array_filter($fields, function ($field) use ($baseFields) {
+				return in_array($field, $baseFields);
+			}, ARRAY_FILTER_USE_KEY);
+
+			foreach ($fields as $fieldName => $value) {
+				$config = $meta->config($fieldName);
+				//Check if it's a complex field
+				if (array_key_exists($fieldName, $complex)) {
+					foreach ($value as $row) {
+						foreach ($row as $fName => $fValue) {
+							if (in_array($fName, $complex[$fieldName])) {
+								$terms = $this->addTerms($fValue, $terms, $config);
+							}
+						}
+					}
+				} else {
+
+					$terms = $this->addTerms($value, $terms, $config);
+				}
+			}
+		}
+		return $terms;
+
+	}
+
+	protected function addTerms(string $value, array $terms, array $config):array
+	{
+		$taxonomy = jvbNoBase($config['taxonomy']);
+		if (empty($value)) {
+			return $terms;
+		}
+		$ids = array_map('absint', explode(',',$value));
+		$cache = Cache::for('term_data')->connect('taxonomy');
+		$cache->flush();
+		if (!array_key_exists($taxonomy, $terms)) {
+			$terms[$taxonomy] = [];
+			$registrar = Registrar::getInstance($taxonomy);
+			$terms[$taxonomy]['icon'] = $registrar ? $registrar->getIcon() : jvbDefaultIcon();;
+		}
+		foreach ($ids as $id) {
+			$data = $cache->remember(
+				$id,
+				function () use ($id, $taxonomy) {
+					$term = get_term($id, $taxonomy);
+					if ($term && !is_wp_error($term)) {
+						return [
+							'id'	=> $term->term_id,
+							'name'	=> $term->name,
+							'slug'	=> $term->slug,
+							'parent'	=> $term->parent,
+							'path'	=> JVB()->routes('term')->getTermPath($term->term_id, $term->name, $taxonomy),
+							'taxonomy'	=> jvbNoBase($term->taxonomy),
+							'count'	=> $term->count,
+						];
+					}
+					return [];
+				}
+			);
+			if (!empty($data)) {
+				$terms[$taxonomy][$id] = $data;
+			}
+		}
+		return $terms;
+	}
+}

--
Gitblit v1.10.0