12, 'page' => 2, 'filters' => ['category' => ['news']]]); * $result = Query::run($args, $settings); // ['ids' => int[], 'found' => int, 'max_pages' => int] * * Context keys (all optional): * - post_id int current post (exclude current, related posts) * - page int 1-based page of the loop * - filters array taxonomy => term slugs/ids from the Taxonomy filter widget (OR inside a taxonomy) * - exclude int[] ids to skip (avoid duplicates across loops) * - main array query vars of the main query (source "current"), see self::MAIN_VARS * - related int[] term ids of the current post for the related taxonomy (else read with wp_get_post_terms) * * Always: `post_status = publish` and never a password-protected post, so a loop only lists public content. * Filter: `zadex/loop/query_args` ($args, $settings, $context). */ final class Query { /** Main-query vars kept for the "current query" source (and accepted from the REST `qv` parameter). */ public const MAIN_VARS = ['post_type', 'cat', 'category_name', 'tag', 'tag_id', 'author', 'author_name', 's', 'taxonomy', 'term', 'year', 'monthnum', 'day', 'm', 'w', 'post_format', 'product_cat', 'product_tag']; public const ORDERBY = ['date', 'modified', 'title', 'menu_order', 'comment_count', 'author', 'ID', 'post__in', 'rand', 'name']; /** @var null|callable(array): array{ids: int[], found: int} test seam replacing WP_Query */ public static $fetcher = null; public static function build_args(array $settings, array $context = []): array { $source = (string) ($settings['query_source'] ?? 'post_type'); $source = in_array($source, ['post_type', 'current', 'manual', 'related'], true) ? $source : 'post_type'; $per_page = max(1, min(100, (int) ($settings['query_per_page'] ?? 6))); $page = max(1, (int) ($context['page'] ?? 1)); $offset = max(0, (int) ($settings['query_offset'] ?? 0)); $post_id = max(0, (int) ($context['post_id'] ?? 0)); $args = [ 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => $per_page, 'ignore_sticky_posts' => array_key_exists('query_ignore_sticky', $settings) ? (bool) $settings['query_ignore_sticky'] : true, 'no_found_rows' => false, ]; // WordPress ignores `paged` when `offset` is set, so the page is folded into the offset. if ($offset > 0) { $args['offset'] = $offset + ($page - 1) * $per_page; } else { $args['paged'] = $page; } $tax_query = []; $not_in = []; $post_in = null; switch ($source) { case 'current': $main = self::main_vars(is_array($context['main'] ?? null) ? $context['main'] : []); $args = array_merge($main, $args); if (!isset($args['post_type'])) { $args['post_type'] = 'post'; } break; case 'manual': $args['post_type'] = 'any'; $post_in = self::ids($settings['query_ids'] ?? ''); break; case 'related': $tax = self::taxonomy((string) ($settings['query_related_by'] ?? 'category')) ?: 'category'; $type = $post_id > 0 && function_exists('get_post_type') ? (string) get_post_type($post_id) : ''; $args['post_type'] = $type !== '' ? $type : 'post'; $terms = isset($context['related']) && is_array($context['related']) ? array_map('intval', $context['related']) : self::post_term_ids($post_id, $tax); $terms = array_values(array_filter($terms, static fn(int $t): bool => $t > 0)); if (!$terms) { $post_in = []; // nothing related } else { $tax_query[] = ['taxonomy' => $tax, 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN']; } if ($post_id > 0) { $not_in[] = $post_id; } break; default: $type = sanitize_key((string) ($settings['query_post_type'] ?? 'post')); $args['post_type'] = $type !== '' ? $type : 'post'; } /* ---- terms (include / exclude) ---- */ if ($source !== 'manual') { $default_tax = self::taxonomy((string) ($settings['query_taxonomy'] ?? 'category')) ?: 'category'; $operator = ($settings['query_terms_operator'] ?? 'IN') === 'AND' ? 'AND' : 'IN'; foreach (self::term_clauses((string) ($settings['query_terms'] ?? ''), $default_tax, $operator) as $clause) { $tax_query[] = $clause; } foreach (self::term_clauses((string) ($settings['query_exclude_terms'] ?? ''), $default_tax, 'NOT IN') as $clause) { $tax_query[] = $clause; } } /* ---- Taxonomy filter widget (T119): OR inside one taxonomy, AND between taxonomies ---- */ foreach ((array) ($context['filters'] ?? []) as $tax => $terms) { $tax = self::taxonomy((string) $tax); $terms = is_array($terms) ? $terms : explode(',', (string) $terms); $slugs = array_values(array_unique(array_filter(array_map(static fn($t): string => sanitize_title_for_query((string) $t), $terms), 'strlen'))); if ($tax !== '' && $slugs) { $tax_query[] = ['taxonomy' => $tax, 'field' => 'slug', 'terms' => $slugs, 'operator' => 'IN']; } } if ($tax_query) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the loop widget filters by the taxonomies the user picked. $args['tax_query'] = count($tax_query) > 1 ? array_merge(['relation' => 'AND'], $tax_query) : $tax_query; } /* ---- authors ---- */ $authors = self::author_ids((string) ($settings['query_authors'] ?? '')); if ($authors) { $args['author__in'] = $authors; } $not_authors = self::author_ids((string) ($settings['query_exclude_authors'] ?? '')); if ($not_authors) { $args['author__not_in'] = $not_authors; } /* ---- excluded posts ---- */ $not_in = array_merge($not_in, self::ids($settings['query_exclude_ids'] ?? '')); if ($post_id > 0 && (!array_key_exists('query_exclude_current', $settings) || !empty($settings['query_exclude_current']))) { $not_in[] = $post_id; } foreach ((array) ($context['exclude'] ?? []) as $id) { if ((int) $id > 0) { $not_in[] = (int) $id; } } $not_in = array_values(array_unique($not_in)); if ($post_in !== null) { // post__not_in is ignored next to post__in: remove the excluded ids from the list instead. $post_in = array_values(array_diff($post_in, $not_in)); $args['post__in'] = $post_in ?: [0]; } elseif ($not_in) { // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- user-chosen exclusions (and the current post), a short id list. $args['post__not_in'] = $not_in; } /* ---- date ---- */ $date = self::date_query($settings); if ($date) { $args['date_query'] = [$date]; } /* ---- order ---- */ $orderby = (string) ($settings['query_orderby'] ?? 'date'); $orderby = in_array($orderby, self::ORDERBY, true) ? $orderby : 'date'; if ($orderby === 'post__in' && $post_in === null) { $orderby = 'date'; } $args['orderby'] = $orderby; $args['order'] = strtoupper((string) ($settings['query_order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; return (array) apply_filters('zadex/loop/query_args', $args, $settings, $context); } /** * Runs the query. `found` is the number of matching posts after the offset; `max_pages` uses the loop's page * size. Non-viewable post types (private CPTs, zadex_template …) are replaced by `post`. * * @return array{ids: int[], found: int, max_pages: int} */ public static function run(array $args, array $settings = []): array { $args = self::guard($args); $args['fields'] = 'ids'; if (is_callable(self::$fetcher)) { $res = (self::$fetcher)($args); $ids = array_map('intval', (array) ($res['ids'] ?? [])); $found = (int) ($res['found'] ?? count($ids)); } elseif (class_exists('\WP_Query')) { $query = new \WP_Query($args); $ids = array_map('intval', (array) $query->posts); $found = (int) $query->found_posts; } else { return ['ids' => [], 'found' => 0, 'max_pages' => 0]; } $per_page = max(1, (int) ($args['posts_per_page'] ?? 6)); $offset = max(0, (int) ($settings['query_offset'] ?? 0)); $found = max(0, $found - $offset); return ['ids' => $ids, 'found' => $found, 'max_pages' => (int) ceil($found / $per_page)]; } /** Post types must be viewable; statuses stay `publish`. */ public static function guard(array $args): array { $args['post_status'] = 'publish'; $args['has_password'] = false; $types = $args['post_type'] ?? 'post'; if ($types === 'any') { return $args; } $clean = []; foreach ((array) $types as $type) { $type = sanitize_key((string) $type); if ($type !== '' && self::viewable($type)) { $clean[] = $type; } } $args['post_type'] = $clean ? (count($clean) === 1 ? $clean[0] : $clean) : 'post'; return $args; } private static function viewable(string $type): bool { if (in_array($type, ['zadex_template', 'zadex_library', 'attachment', 'revision', 'nav_menu_item', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_global_styles'], true)) { return false; } if (function_exists('is_post_type_viewable') && function_exists('get_post_type_object')) { $object = get_post_type_object($type); return $object ? (bool) is_post_type_viewable($object) : false; } return function_exists('post_type_exists') ? post_type_exists($type) : true; } /** Main query vars (whitelisted, scalar) for the "current query" source. */ public static function main_vars(array $vars): array { $out = []; foreach (self::MAIN_VARS as $key) { if (!isset($vars[$key]) || $vars[$key] === '' || $vars[$key] === 0 || $vars[$key] === '0') { continue; } $value = $vars[$key]; if ($key === 'post_type') { $out[$key] = is_array($value) ? array_values(array_map(static fn($v): string => sanitize_key((string) $v), $value)) : sanitize_key((string) $value); } elseif (is_scalar($value)) { $out[$key] = in_array($key, ['cat', 'tag_id', 'author', 'year', 'monthnum', 'day', 'w'], true) ? (int) $value : sanitize_text_field((string) $value); } } return $out; } /** Main query vars of the current request (for the loop's `data-zx-loop` → REST `qv`). */ public static function current_main_vars(): array { $main = \Zadex\Theme\Widgets\ThemeData::main_query(); // the template preview's query on an archive preview return is_object($main) && isset($main->query_vars) && is_array($main->query_vars) ? self::main_vars($main->query_vars) : []; } /** @return int[] positive ids from "12, 34 56" */ public static function ids($value): array { $parts = is_array($value) ? $value : preg_split('/[\s,]+/', (string) $value); $out = []; foreach ($parts ?: [] as $part) { if (is_numeric($part) && (int) $part > 0) { $out[] = (int) $part; } } return array_values(array_unique($out)); } /** * "news, 12, post_tag:featured" → tax_query clauses (one per taxonomy + field). * * @return array */ public static function term_clauses(string $value, string $default_tax, string $operator): array { $groups = []; foreach (preg_split('/\s*,\s*/', trim($value)) ?: [] as $token) { if ($token === '') { continue; } $tax = $default_tax; if (str_contains($token, ':')) { [$tax, $token] = array_map('trim', explode(':', $token, 2)); $tax = self::taxonomy($tax); } if ($tax === '' || $token === '') { continue; } $field = ctype_digit($token) ? 'term_id' : 'slug'; $term = $field === 'term_id' ? (int) $token : sanitize_title_for_query($token); if ($term === '' || $term === 0) { continue; } $groups[$tax . '|' . $field][] = $term; } $clauses = []; foreach ($groups as $key => $terms) { [$tax, $field] = explode('|', $key); $clauses[] = ['taxonomy' => $tax, 'field' => $field, 'terms' => array_values(array_unique($terms)), 'operator' => $operator]; } return $clauses; } /** @return int[] user ids from "1, admin" (logins resolved with get_user_by) */ public static function author_ids(string $value): array { $out = []; foreach (preg_split('/\s*,\s*/', trim($value)) ?: [] as $token) { if ($token === '') { continue; } if (ctype_digit($token)) { $out[] = (int) $token; } elseif (function_exists('get_user_by')) { $user = get_user_by('login', sanitize_user($token)); if ($user && isset($user->ID)) { $out[] = (int) $user->ID; } } } return array_values(array_unique(array_filter($out))); } /** @return array|null one date_query clause */ public static function date_query(array $settings): ?array { $range = (string) ($settings['query_date'] ?? 'any'); $relative = ['day' => '1 day ago', 'week' => '1 week ago', 'month' => '1 month ago', 'year' => '1 year ago']; if (isset($relative[$range])) { return ['after' => $relative[$range], 'inclusive' => true]; } if ($range !== 'custom') { return null; } $clause = []; foreach (['after' => 'query_date_after', 'before' => 'query_date_before'] as $key => $id) { $v = trim((string) ($settings[$id] ?? '')); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) { $clause[$key] = $v; } } if (!$clause) { return null; } $clause['inclusive'] = true; return $clause; } public static function taxonomy(string $tax): string { return preg_replace('/[^a-z0-9_-]/', '', strtolower($tax)) ?? ''; } /** @return int[] */ private static function post_term_ids(int $post_id, string $tax): array { if ($post_id <= 0 || !function_exists('wp_get_post_terms')) { return []; } $terms = wp_get_post_terms($post_id, $tax, ['fields' => 'ids']); return is_array($terms) ? array_map('intval', $terms) : []; } }