,operator:string}> $taxonomies * @param array $customFields * @param list,operator:string}> $requiredTaxonomies * @param list $includeEvents * @param list $excludeEvents */ public function __construct( public readonly string $start, public readonly string $end, public readonly bool $includePast, public readonly string $status, public readonly bool $featured, public readonly int $venue, public readonly int $organizer, public readonly string $mode, public readonly int $series, public readonly int $relatedTo, public readonly string $category, public readonly string $tag, public readonly array $taxonomies, public readonly string $keyword, public readonly ?float $minPrice, public readonly ?float $maxPrice, public readonly int $weekday, public readonly string $timeFrom, public readonly string $timeTo, public readonly ?float $latitude, public readonly ?float $longitude, public readonly ?float $radius, public readonly int $limit, public readonly array $customFields = [], public readonly array $requiredTaxonomies = [], public readonly array $includeEvents = [], public readonly array $excludeEvents = [], ) { } /** @param array $input */ public static function from(array $input = [], bool $defaultIncludePast = false): self { $value = static function (string $snake, string $camel, mixed $default = '') use ($input): mixed { return $input[$snake] ?? $input[$camel] ?? $default; }; $nullableNumber = static function (mixed $raw): ?float { return $raw === '' || $raw === null || ! is_numeric($raw) ? null : (float) $raw; }; $status = EventMeta::sanitizeStatusFilter($value('status', 'pcwpStatus')); $mode = EventMeta::sanitizeModeFilter($value('mode', 'pcwpMode')); $weekday = (int) $value('weekday', 'pcwpWeekday', -1); $category = self::slug((string) $value('category', 'pcwpCategory')); $tag = self::slug((string) $value('tag', 'pcwpTag')); $taxonomies = self::taxonomies($value('taxonomies', 'pcwpTaxonomies', [])); foreach (['pcwp_event_category' => $category, 'pcwp_event_tag' => $tag] as $taxonomy => $term) { if ($term === '') { continue; } $existing = $taxonomies[$taxonomy]['terms'] ?? []; $taxonomies[$taxonomy] = [ 'terms' => array_values(array_unique(array_merge($existing, [$term]))), 'operator' => $taxonomies[$taxonomy]['operator'] ?? 'OR', ]; } return new self( self::date((string) $value('start', 'pcwpStart')), self::date((string) $value('end', 'pcwpEnd'), true), self::boolean($value('include_past', 'pcwpIncludePast', $input['past_events'] ?? $defaultIncludePast)), $status, self::boolean($value('featured', 'pcwpFeatured', false)), abs((int) $value('venue', 'pcwpVenue', 0)), abs((int) $value('organizer', 'pcwpOrganizer', 0)), $mode, abs((int) $value('series', 'pcwpSeries', 0)), abs((int) $value('related_to', 'pcwpRelatedTo', 0)), $category, $tag, $taxonomies, self::text((string) $value('keyword', 'pcwpKeyword')), $nullableNumber($value('min_price', 'pcwpMinPrice')), $nullableNumber($value('max_price', 'pcwpMaxPrice')), $weekday >= 0 && $weekday <= 6 ? $weekday : -1, self::time((string) $value('time_from', 'pcwpTimeFrom')), self::time((string) $value('time_to', 'pcwpTimeTo')), $nullableNumber($value('latitude', 'pcwpLatitude')), $nullableNumber($value('longitude', 'pcwpLongitude')), $nullableNumber($value('radius', 'pcwpRadius')), min(500, max(1, (int) $value('limit', 'perPage', 100))), is_array($value('custom_fields', 'pcwpCustomFields', [])) ? $value('custom_fields', 'pcwpCustomFields', []) : [], self::requiredTaxonomies($input['required_taxonomies'] ?? []), self::eventIds($value('include_events', 'pcwpIncludeEvents', [])), self::eventIds($value('exclude_events', 'pcwpExcludeEvents', [])), ); } /** @return array */ public function toRepositoryQuery(): array { return [ 'start' => $this->start, 'end' => $this->end, 'past_events' => $this->includePast, 'status' => $this->status, 'featured' => $this->featured, 'venue' => $this->venue, 'organizer' => $this->organizer, 'mode' => $this->mode, 'series' => $this->series, 'related_to' => $this->relatedTo, 'category' => $this->category, 'tag' => $this->tag, 'taxonomies' => $this->taxonomies, 'keyword' => $this->keyword, 'min_price' => $this->minPrice, 'max_price' => $this->maxPrice, 'weekday' => $this->weekday, 'time_from' => $this->timeFrom, 'time_to' => $this->timeTo, 'latitude' => $this->latitude, 'longitude' => $this->longitude, 'radius' => $this->radius, 'custom_fields' => $this->customFields, 'required_taxonomies' => $this->requiredTaxonomies, 'include_events' => $this->includeEvents, 'exclude_events' => $this->excludeEvents, 'limit' => $this->limit, ]; } /** @return array */ public function toWpQueryArgs(): array { $args = $this->toRepositoryQuery(); // Event tags use pcwp_event_tag via tax_query, not WordPress post_tag. unset($args['tag']); $args['pcwp_event_query'] = true; $args['post_type'] = 'pcwp_event'; $args['post_status'] = 'publish'; $args['posts_per_page'] = $this->limit; $args['orderby'] = 'pcwp_start'; $args['order'] = 'ASC'; $args['s'] = $this->keyword; $taxQuery = []; $clauses = $this->requiredTaxonomies; foreach ($this->taxonomies as $taxonomy => $filter) { $clauses[] = ['taxonomy' => $taxonomy] + $filter; } foreach ($clauses as $filter) { $taxonomy = $filter['taxonomy']; if ($filter['terms'] === []) { continue; } $taxQuery[] = [ 'taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $filter['terms'], 'operator' => $filter['operator'] === 'AND' ? 'AND' : 'IN', ]; } if ($this->includeEvents !== []) { // WP_Query does not combine post__in and post__not_in reliably. // [0] deliberately matches nothing when every included ID is excluded. $args['post__in'] = array_values(array_diff($this->includeEvents, $this->excludeEvents)) ?: [0]; } elseif ($this->excludeEvents !== []) { $args['post__not_in'] = $this->excludeEvents; } if ($taxQuery !== []) { $taxQuery['relation'] = 'AND'; $args['tax_query'] = $taxQuery; } $allowedFields = apply_filters('pcwp_public_custom_fields', [], 0); if (is_array($allowedFields)) { $metaQuery = []; foreach ($this->customFields as $key => $value) { if (in_array($key, $allowedFields, true)) { $metaQuery[] = ['key' => $key, 'value' => (string) $value, 'compare' => '=']; } } if ($metaQuery !== []) { $args['meta_query'] = $metaQuery; } } return apply_filters('pcwp_event_query_args', $args, $this); } /** @param array $input */ public function withOverrides(array $input): self { $base = $this->toRepositoryQuery(); if (isset($input['taxonomies']) || isset($input['category']) || isset($input['tag'])) { foreach ($base['taxonomies'] as $taxonomy => $filter) { $base['required_taxonomies'][] = ['taxonomy' => $taxonomy] + $filter; } $base['taxonomies'] = []; $base['category'] = ''; $base['tag'] = ''; } return self::from(array_merge($base, $input), $this->includePast); } /** @return list,operator:string}> */ private static function requiredTaxonomies(mixed $value): array { $result = []; foreach (is_array($value) ? $value : [] as $clause) { if (! is_array($clause)) { continue; } $taxonomy = sanitize_key((string) ($clause['taxonomy'] ?? '')); $normalized = self::taxonomies([$taxonomy => $clause]); if (isset($normalized[$taxonomy])) { $result[] = ['taxonomy' => $taxonomy] + $normalized[$taxonomy]; } } return $result; } /** @return list */ private static function eventIds(mixed $value): array { if (! is_array($value)) { return []; } return array_values(array_unique(array_map('intval', array_filter(array_slice($value, 0, 500), static fn (mixed $id): bool => is_scalar($id) && ctype_digit((string) $id) && (int) $id > 0)))); } private static function boolean(mixed $value): bool { return filter_var($value, FILTER_VALIDATE_BOOLEAN); } private static function date(string $value, bool $endOfDay = false): string { $value = trim($value); if ($value === '') { return ''; } try { $timezone = new DateTimeZone(wp_timezone_string()); if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { $date = new DateTimeImmutable($value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'), $timezone); return $date->format(DATE_ATOM); } return (new DateTimeImmutable($value, $timezone))->format(DATE_ATOM); } catch (\Exception) { return ''; } } /** @return array,operator:string}> */ private static function taxonomies(mixed $value): array { if (! is_array($value)) { return []; } $result = []; foreach ($value as $taxonomy => $filter) { $taxonomy = sanitize_key((string) $taxonomy); if ($taxonomy === '' || ! is_array($filter)) { continue; } $rawTerms = $filter['terms'] ?? []; if (is_string($rawTerms)) { $rawTerms = [$rawTerms]; } if (! is_array($rawTerms)) { continue; } $terms = []; foreach ($rawTerms as $term) { if (! is_scalar($term)) { continue; } $slug = self::slug((string) $term); if ($slug !== '') { $terms[] = $slug; } } if ($terms !== []) { $result[$taxonomy] = [ 'terms' => array_values(array_unique($terms)), 'operator' => strtoupper((string) ($filter['operator'] ?? 'OR')) === 'AND' ? 'AND' : 'OR', ]; } } return $result; } private static function time(string $value): string { return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $value) === 1 ? $value : ''; } private static function slug(string $value): string { return sanitize_title($value); } private static function text(string $value): string { return trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', wp_strip_all_tags($value))); } }