repository = $repository; $this->feed_query = $feed_query; $this->cache = $cache; $this->properties = $properties ?? new CustomProperties(); } /** * Registers the REST route. */ public function register_routes(): void { register_rest_route( 'mapsemble/v1', '/features', array( 'methods' => 'GET', 'callback' => array( $this, 'handle' ), 'permission_callback' => '__return_true', 'args' => array( 'mapId' => array( 'type' => 'string' ), 'extent' => array( 'type' => 'string' ), 'offset' => array( 'type' => 'integer' ), 'limit' => array( 'type' => 'integer' ), 'orderBy' => array( 'type' => 'string' ), 'language' => array( 'type' => 'string' ), 'filters' => array( 'type' => 'string' ), 'remaining' => array( 'type' => 'integer' ), ), ) ); } /** * Answers the request, from the cache when it can. * * The config lookup and the refusal stay in front of the cache: they are * cheap, and they are the decisions a changed configuration must flip * at once. * * @param WP_REST_Request $request The request. */ public function handle( WP_REST_Request $request ): WP_REST_Response { $config = $this->repository->find_by_map_id( (string) $request->get_param( 'mapId' ) ); if ( null === $config ) { // Not an error: the wire contract says an unknown map degrades to // an empty collection. Mapsemble polls feeds it may no longer own. return $this->respond( array(), 0 ); } if ( $config->is_hosted() ) { // A hosted map has no projection on this site. Answering anything // but 404 would publish whatever source settings survived the // switch to hosted — which are deliberately kept, not cleared. return $this->hosted_map_refusal(); } // Normalised the way build() reads them, so two spellings of one // question share an entry — and an offset without a limit, which the // query ignores, does not split the full set into copies. $limit = null !== $request->get_param( 'limit' ) ? max( 1, (int) $request->get_param( 'limit' ) ) : null; return $this->cache->remember( 'features', $config, $request, array( 'extent' => (string) $request->get_param( 'extent' ), 'offset' => null !== $limit ? max( 0, (int) $request->get_param( 'offset' ) ) : 0, 'limit' => $limit, 'orderBy' => (string) $request->get_param( 'orderBy' ), 'language' => (string) $request->get_param( 'language' ), 'filters' => (string) $request->get_param( 'filters' ), 'remaining' => (int) $request->get_param( 'remaining' ), ), fn (): WP_REST_Response => $this->build( $config, $request ) ); } /** * Builds the FeatureCollection response. * * @param MapConfig $config The matched, site-fed map. * @param WP_REST_Request $request The request. */ private function build( MapConfig $config, WP_REST_Request $request ): WP_REST_Response { $bbox = BoundingBox::parse( (string) $request->get_param( 'extent' ) ); $limit = null !== $request->get_param( 'limit' ) ? max( 1, (int) $request->get_param( 'limit' ) ) : null; $offset = max( 0, (int) $request->get_param( 'offset' ) ); $remaining_only = (bool) (int) $request->get_param( 'remaining' ); // The map's served set, as the edit screen counts it; the visitor's // filters, paging and ordering go on top. $args = $this->feed_query->base_args( $config, $bbox ); $filtered = $this->feed_query->filter_clauses( $config, $this->feed_query->parse_filters( (string) $request->get_param( 'filters' ) ) ); foreach ( $filtered['meta_query'] as $clause ) { $args['meta_query'][] = $clause; } if ( array() !== $filtered['tax_query'] ) { $args['tax_query'] = array_merge( $args['tax_query'] ?? array( 'relation' => 'AND' ), $filtered['tax_query'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- always-on and visitor filters are the map's own subset. } // Absent limit = full set (SaaS contract); WP_Query ignores offset // when posts_per_page is -1, matching "offset without limit is // ignored". $args['posts_per_page'] = $limit ?? -1; if ( null !== $limit && $offset > 0 ) { $args['offset'] = $offset; } $order = $this->feed_query->resolve_order( $config, (string) $request->get_param( 'orderBy' ) ); if ( null !== $order ) { if ( 'entropy' === ( $order['type'] ?? '' ) ) { // The flag doubles as the RAND() seed for the fallback path. $args['mapsemble_entropy'] = '' !== $config->map_id ? $config->map_id : 'mapsemble'; } elseif ( 'meta' === ( $order['orderby'] ?? '' ) ) { // EXISTS alone would inner-join and drop rows missing the // meta; the OR pair keeps them, sorted to the NULL end. $args['meta_query']['mapsemble_orderby_group'] = array( 'relation' => 'OR', 'mapsemble_orderby' => array( 'key' => (string) ( $order['meta_key'] ?? '' ), 'compare' => 'EXISTS', 'type' => ! empty( $order['numeric'] ) ? 'NUMERIC' : 'CHAR', ), array( 'key' => (string) ( $order['meta_key'] ?? '' ), 'compare' => 'NOT EXISTS', ), ); $args['orderby'] = array( 'mapsemble_orderby' => (string) ( $order['direction'] ?? 'ASC' ), 'ID' => 'ASC', ); } else { $args['orderby'] = array( (string) ( $order['orderby'] ?? 'date' ) => (string) ( $order['direction'] ?? 'ASC' ), 'ID' => 'ASC', ); } } /** * Filters the WP_Query args for the features feed. * * @param array $args WP_Query arguments. * @param MapConfig $config The matched map config. * @param WP_REST_Request $request The request (filters/orderBy/language available here). */ $args = (array) apply_filters( 'mapsemble_features_query_args', $args, $config, $request ); $query = new WP_Query( $args ); $post_ids = array_map( 'intval', $query->posts ); update_meta_cache( 'post', $post_ids ); if ( $this->wants_terms( $config ) ) { // One term-cache prime instead of a query per feature; the feed // query itself keeps update_post_term_cache off. update_object_term_cache( $post_ids, $config->post_type ); } $features = array(); foreach ( $post_ids as $post_id ) { $lat = get_post_meta( $post_id, Coordinates::META_LAT, true ); $lng = get_post_meta( $post_id, Coordinates::META_LNG, true ); if ( ! is_numeric( $lat ) || ! is_numeric( $lng ) ) { continue; } // Custom properties ride every leg: the remaining dots are // styled by the same marker rules as the paged ones. Only `id` // stays off the remaining leg, keeping it lean (Drupal parity). $properties = $this->properties->for_post( $config, $post_id ); if ( ! $remaining_only ) { $properties = array( 'id' => (string) $post_id ) + $properties; } /** * Filters a feature's GeoJSON properties. * * @param array $properties Property map. * @param int $post_id Post ID. * @param MapConfig $config The matched map config. * @param WP_REST_Request $request The request. */ $properties = (array) apply_filters( 'mapsemble_feature_properties', $properties, $post_id, $config, $request ); $features[] = array( 'type' => 'Feature', 'id' => (string) $post_id, 'geometry' => array( 'type' => 'Point', 'coordinates' => array( (float) $lng, (float) $lat ), ), // GeoJSON spec: properties must serialize as an object, // so an empty set becomes {} rather than []. 'properties' => (object) $properties, ); } return $this->respond( $features, (int) $query->found_posts ); } /** * Whether any property row references a taxonomy. * * @param MapConfig $config The map. */ private function wants_terms( MapConfig $config ): bool { foreach ( $config->properties as $row ) { if ( is_array( $row ) && str_starts_with( (string) ( $row['field'] ?? '' ), 'tax:' ) ) { return true; } } return false; } /** * Wraps features in the FeatureCollection envelope. * * @param array $features GeoJSON features. * @param int $total_count Unpaged total. */ private function respond( array $features, int $total_count ): WP_REST_Response { $response = new WP_REST_Response( array( 'type' => 'FeatureCollection', 'features' => $features, 'totalCount' => $total_count, ) ); $response->header( 'Access-Control-Allow-Origin', '*' ); return $response; } }