|null $bbox A parsed extent (BoundingBox::parse()), or null for the whole set. * @return array WP_Query arguments. */ public function base_args( MapConfig $config, ?array $bbox = null ): array { $meta_query = array( 'relation' => 'AND', array( 'key' => Coordinates::META_HIDDEN, 'compare' => 'NOT EXISTS', ), ); if ( null !== $bbox ) { $meta_query['mapsemble_lat'] = array( 'key' => Coordinates::META_LAT, 'value' => array( $bbox['minLat'], $bbox['maxLat'] ), 'type' => 'DECIMAL(9,6)', 'compare' => 'BETWEEN', ); $meta_query['mapsemble_lng'] = array( 'key' => Coordinates::META_LNG, 'value' => array( $bbox['minLon'], $bbox['maxLon'] ), 'type' => 'DECIMAL(9,6)', 'compare' => 'BETWEEN', ); } else { $meta_query['mapsemble_lat'] = array( 'key' => Coordinates::META_LAT, 'compare' => 'EXISTS', ); $meta_query['mapsemble_lng'] = array( 'key' => Coordinates::META_LNG, 'compare' => 'EXISTS', ); } $conditions = $this->static_condition_clauses( $config ); foreach ( $conditions['meta_query'] as $clause ) { $meta_query[] = $clause; } $args = array( 'post_type' => $config->post_type, 'post_status' => 'publish', 'fields' => 'ids', 'no_found_rows' => false, 'ignore_sticky_posts' => true, 'suppress_filters' => false, 'update_post_term_cache' => false, 'posts_per_page' => -1, 'orderby' => array( 'date' => 'DESC', 'ID' => 'ASC', ), 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- the feed is inherently a meta lookup; documented limits in readme. ); if ( array() !== $conditions['tax_query'] ) { $args['tax_query'] = array_merge( array( 'relation' => 'AND' ), $conditions['tax_query'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- always-on filters are the map's own subset. } return $args; } /** * How many posts the map serves right now, ignoring visitor filters. * * The feed's own base query, so the number on the edit screen is the * feed's unfiltered totalCount. The `mapsemble_features_query_args` * filter is not applied: it is handed a live feed request, and there is * none here. Query filters that act on the admin context — a * multilingual plugin scoping posts to the admin language, say — can * also make this differ from what the public feed serves. * * @param MapConfig $config The map. */ public function served_count( MapConfig $config ): int { $args = $this->base_args( $config ); $args['posts_per_page'] = 1; $args['no_found_rows'] = false; // Only the total is wanted; sorting the whole set for one row is waste. $args['orderby'] = 'none'; return (int) ( new WP_Query( $args ) )->found_posts; } /** * Slugs in `equals` conditions on taxonomies that no term has. * * Such a slug can never match, so a condition made only of them empties * the map. A taxonomy that is not registered has no terms at all, and * its condition fails closed in static_condition_clauses(), so every * slug on it is reported. Meta conditions and the exists / not_exists * modes carry no slugs and are not looked at. * * @param MapConfig $config The map. * @return string[] Unknown slugs, unique, in the order entered. */ public function unknown_term_slugs( MapConfig $config ): array { $unknown = array(); foreach ( $config->static_conditions as $row ) { if ( ! is_array( $row ) || 'equals' !== (string) ( $row['mode'] ?? '' ) ) { continue; } $ref = FieldRef::parse( $row['field'] ?? null ); if ( null === $ref || FieldRef::KIND_TAX !== $ref['kind'] ) { continue; } foreach ( $this->split_slugs( $row['value'] ?? '' ) as $slug ) { if ( ! in_array( $slug, $unknown, true ) && false === get_term_by( 'slug', $slug, $ref['name'] ) ) { $unknown[] = $slug; } } } return $unknown; } /** * Splits an always-on filter's comma-separated slug list. * * The one parser for both the query and the unknown-slug check, so the * warning speaks about exactly the slugs the feed asks for. Only empty * entries are dropped: `0` is a valid slug, which a bare array_filter() * would lose — and losing the only slug dropped the whole condition, * serving every post. (WP_Tax_Query cannot match a `0` slug itself, so * such a condition now fails closed instead.) * * @param mixed $value The stored value. * @return string[] Trimmed, non-empty slugs, in the order entered. */ private function split_slugs( $value ): array { return array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ), static fn ( string $slug ): bool => '' !== $slug ) ); } /** * Builds tax_query / meta_query fragments for a map's always-on filters. * * A condition on a taxonomy that is not registered is kept anyway: the * resulting tax_query matches nothing, which fails closed. Failing open * would publish exactly the posts the condition was meant to exclude. * * @param MapConfig $config The map. * @return array{tax_query: array, meta_query: array} */ public function static_condition_clauses( MapConfig $config ): array { $tax_query = array(); $meta_query = array(); foreach ( $config->static_conditions as $row ) { if ( ! is_array( $row ) ) { continue; } $ref = FieldRef::parse( $row['field'] ?? null ); if ( null === $ref ) { continue; } $mode = (string) ( $row['mode'] ?? '' ); if ( FieldRef::KIND_TAX === $ref['kind'] ) { if ( 'equals' === $mode ) { $slugs = $this->split_slugs( $row['value'] ?? '' ); if ( array() === $slugs ) { continue; } $tax_query[] = array( 'taxonomy' => $ref['name'], 'field' => 'slug', 'terms' => $slugs, 'operator' => 'IN', ); } elseif ( 'exists' === $mode ) { $tax_query[] = array( 'taxonomy' => $ref['name'], 'operator' => 'EXISTS', ); } elseif ( 'not_exists' === $mode ) { $tax_query[] = array( 'taxonomy' => $ref['name'], 'operator' => 'NOT EXISTS', ); } continue; } if ( 'equals' === $mode ) { $meta_query[] = array( 'key' => $ref['name'], 'value' => (string) ( $row['value'] ?? '' ), 'compare' => '=', ); } elseif ( 'exists' === $mode ) { $meta_query[] = array( 'key' => $ref['name'], 'compare' => 'EXISTS', ); } elseif ( 'not_exists' === $mode ) { $meta_query[] = array( 'key' => $ref['name'], 'compare' => 'NOT EXISTS', ); } } return array( 'tax_query' => $tax_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- a clause fragment, not query args. 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- see above. ); } /** * Parses the wire `filters` parameter into slug => value pairs. * * Empty strings and empty arrays are stripped (Drupal parity): a cleared * widget filter means "not filtering on this", not "match nothing". * Malformed JSON degrades to no filtering at all. * * @param string $json The raw `filters` request parameter. * @return array Parsed filters. */ public function parse_filters( string $json ): array { if ( '' === $json ) { return array(); } $decoded = json_decode( $json, true ); if ( ! is_array( $decoded ) ) { return array(); } $filters = array(); foreach ( $decoded as $slug => $value ) { if ( ! is_string( $slug ) || '' === $slug ) { continue; } if ( is_array( $value ) ) { $value = array_values( array_filter( $value, 'is_scalar' ) ); if ( array() === $value ) { continue; } $filters[ $slug ] = array_map( 'strval', $value ); } elseif ( is_scalar( $value ) && '' !== (string) $value ) { $filters[ $slug ] = (string) $value; } } return $filters; } /** * Builds clauses for the visitor's filters through the map's mappings. * * Slugs without a mapping are silently ignored — the account may * configure filters on Mapsemble this site never binds, and refusing * them would break the whole map instead of one facet. * * @param MapConfig $config The map. * @param array $filters Parsed filters. * @return array{tax_query: array, meta_query: array} */ public function filter_clauses( MapConfig $config, array $filters ): array { $tax_query = array(); $meta_query = array(); foreach ( $filters as $slug => $value ) { $mapping = $config->filter_mappings[ $slug ] ?? null; if ( ! is_array( $mapping ) ) { continue; } $ref = FieldRef::parse( $mapping['field'] ?? null ); if ( null === $ref ) { continue; } $operator = (string) ( $mapping['operator'] ?? '' ); if ( FieldRef::KIND_TAX === $ref['kind'] ) { if ( ! in_array( $operator, array( 'IN', 'NOT IN' ), true ) ) { continue; } $tax_query[] = array( 'taxonomy' => $ref['name'], 'field' => 'slug', 'terms' => array_map( 'strval', (array) $value ), 'operator' => $operator, ); continue; } switch ( $operator ) { case '=': case '!=': $meta_query[] = array( 'key' => $ref['name'], 'value' => is_array( $value ) ? (string) reset( $value ) : (string) $value, 'compare' => $operator, ); break; case 'CONTAINS': $meta_query[] = array( 'key' => $ref['name'], 'value' => is_array( $value ) ? (string) reset( $value ) : (string) $value, 'compare' => 'LIKE', ); break; case 'IN': case 'NOT IN': $meta_query[] = array( 'key' => $ref['name'], 'value' => array_map( 'strval', (array) $value ), 'compare' => $operator, ); break; } } return array( 'tax_query' => $tax_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- a clause fragment, not query args. 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- see above. ); } /** * Resolves an `orderBy` key against a map's sort mappings. * * The widget sends the remote entry's config key; each mapping cached * that key at save time, with the slug as fallback. No match means no * mapping, and the feed keeps its natural order — an account can offer * sorts on Mapsemble this site never binds. * * @param MapConfig $config The map. * @param string $order_by The requested key. * @return array|null The mapping row, or null. */ public function resolve_order( MapConfig $config, string $order_by ): ?array { if ( '' === $order_by ) { return null; } foreach ( $config->sort_mappings as $slug => $row ) { if ( ! is_array( $row ) ) { continue; } $key = (string) ( $row['key'] ?? '' ); if ( $order_by === $key || ( '' === $key && $order_by === (string) $slug ) || $order_by === (string) $slug ) { return $row; } } return null; } /** * Whether one post belongs to a map's served subset. * * Used by the card endpoint: an excluded post must be indistinguishable * from an absent one, so the same conditions the feed applies decide * whether a card exists at all. Visitor filters take part too — this * goes further than the Drupal module, which leans on entity access, * because the WP feed has no access layer to lean on. * * @param MapConfig $config The map. * @param int $post_id Post id, already known to be * published and of the map's post type. * @param array $filters Parsed visitor filters. */ public function post_matches( MapConfig $config, int $post_id, array $filters = array() ): bool { $static = $this->static_condition_clauses( $config ); $filtered = $this->filter_clauses( $config, $filters ); $clauses = array( 'tax_query' => array_merge( $static['tax_query'], $filtered['tax_query'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- a clause fragment, not query args. 'meta_query' => array_merge( $static['meta_query'], $filtered['meta_query'] ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- see above. ); if ( array() === $clauses['tax_query'] && array() === $clauses['meta_query'] ) { return true; } $args = array( 'post_type' => $config->post_type, 'post_status' => 'publish', 'post__in' => array( $post_id ), 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_term_cache' => false, ); if ( array() !== $clauses['tax_query'] ) { $args['tax_query'] = $clauses['tax_query']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- a single-post membership check. } if ( array() !== $clauses['meta_query'] ) { $args['meta_query'] = $clauses['meta_query']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- see above. } $query = new WP_Query( $args ); return array() !== $query->posts; } }