api = $api; $this->repository = $repository; } /** * Hooks that live outside the edit screen itself. */ public function register(): void { add_action( 'wp_ajax_mapsemble_field_choices', array( $this, 'ajax_field_choices' ) ); } /** * Registers the boxes. Called from MapEditScreen::add_meta_boxes(). */ public function add_boxes(): void { add_meta_box( 'mapsemble_map_static', __( 'Always-on filters', 'mapsemble' ), array( $this, 'render_static_box' ), MapPostType::POST_TYPE, 'normal', 'default' ); add_meta_box( 'mapsemble_map_filters', __( 'Visitor filters', 'mapsemble' ), array( $this, 'render_filters_box' ), MapPostType::POST_TYPE, 'normal', 'default' ); add_meta_box( 'mapsemble_map_sort', __( 'Sort options', 'mapsemble' ), array( $this, 'render_sort_box' ), MapPostType::POST_TYPE, 'normal', 'default' ); add_meta_box( 'mapsemble_map_properties', __( 'Marker style properties', 'mapsemble' ), array( $this, 'render_properties_box' ), MapPostType::POST_TYPE, 'normal', 'default' ); } /** * Renders the sort-options box: the remote map's orderBy entries, each * mappable to a local sort. * * @param WP_Post $post The map being edited. */ public function render_sort_box( WP_Post $post ): void { $config = $this->repository->get( (int) $post->ID ) ?? new MapConfig( (int) $post->ID, (string) $post->post_title, '' ); if ( '' === $config->map_id ) { printf( '
%s
', esc_html__( 'Select a Mapsemble map above and save first — the sort options to map are configured on the map itself, in Mapsemble.', 'mapsemble' ) ); return; } $remote = $this->api->get_remote_map( $config->map_id ); $catalog = Client::catalog( $remote, 'orderBy' ); if ( null === $remote ) { printf( '%s
%s
', wp_kses_post( sprintf( /* translators: %1$s, %2$s: opening and closing tag of a link to the map's Configure panel on Mapsemble; %2$s also holds a screen-reader-only "(opens in a new tab)". Both are empty when there is no link. */ esc_html__( 'This Mapsemble map has no sort options yet. %1$sAdd them on Mapsemble%2$s (Configure panel), then revisit this screen to bind them.', 'mapsemble' ), ...$this->builder_link_tags( $config->map_id, 'configure', $remote ) ) ) ); return; } ?>
|
render_sort_mapping_cells( (string) $slug, $mapping ); ?>
|||
|
render_sort_mapping_cells( (string) $slug, $mapping ); ?>
| render_field_select( 'mapsemble_properties[' . (int) $i . '][field]', (string) ( $row['field'] ?? '' ), $choices ); ?> |
|null $remote The remote map; [] when gone, null when unknown. * @return array{0: string, 1: string} Opening and closing tag. */ private function builder_link_tags( string $map_id, string $panel, ?array $remote ): array { if ( ! is_array( $remote ) || array() === $remote ) { return array( '', '' ); } return array( sprintf( '', esc_url( $this->api->builder_url( $map_id, $panel ) ) ), ' ' . esc_html__( '(opens in a new tab)', 'mapsemble' ) . '', ); } /** * Renders the visitor-filters box: the remote map's filters, each * mappable to a local field. * * @param WP_Post $post The map being edited. */ public function render_filters_box( WP_Post $post ): void { $config = $this->repository->get( (int) $post->ID ) ?? new MapConfig( (int) $post->ID, (string) $post->post_title, '' ); if ( '' === $config->map_id ) { printf( '
%s
', esc_html__( 'Select a Mapsemble map above and save first — the filters to map are configured on the map itself, in Mapsemble.', 'mapsemble' ) ); return; } // Three answers, three renderings: a catalog, "map gone" ([]), and // "could not ask" (null). The latter two must keep every saved mapping // editable, or a save during an outage would silently drop them all. $remote = $this->api->get_remote_map( $config->map_id ); $catalog = Client::catalog( $remote, 'filters' ); if ( null === $remote ) { printf( '%s
%s
', wp_kses_post( sprintf( /* translators: %1$s, %2$s: opening and closing tag of a link to the map's Filters panel on Mapsemble; %2$s also holds a screen-reader-only "(opens in a new tab)". Both are empty when there is no link. */ esc_html__( 'This Mapsemble map has no filters yet. %1$sAdd them on Mapsemble%2$s (Filters panel), then revisit this screen to bind them to your content.', 'mapsemble' ), ...$this->builder_link_tags( $config->map_id, 'filters', $remote ) ) ) ); return; } ?>
()
|
render_field_select( 'mapsemble_filter_mappings[' . esc_attr( (string) $slug ) . '][field]', (string) ( $mapping['field'] ?? '' ), $choices, __( '— Do not map —', 'mapsemble' ) ); ?> | render_operator_select( 'mapsemble_filter_mappings[' . esc_attr( (string) $slug ) . '][operator]', (string) ( $mapping['operator'] ?? '' ) ); ?> |
|
render_field_select( 'mapsemble_filter_mappings[' . esc_attr( (string) $slug ) . '][field]', (string) ( $mapping['field'] ?? '' ), $choices, __( '— Do not map —', 'mapsemble' ) ); ?> | render_operator_select( 'mapsemble_filter_mappings[' . esc_attr( (string) $slug ) . '][operator]', (string) ( $mapping['operator'] ?? '' ) ); ?> |
| render_field_select( 'mapsemble_static_conditions[' . (int) $i . '][field]', (string) ( $row['field'] ?? '' ), $choices ); ?> |
, metaKeys: string[]} $choices Choices for the post type. * @param string $placeholder Empty-option label. */ private function render_field_select( string $name, string $current, array $choices, string $placeholder = '' ): void { if ( '' === $placeholder ) { $placeholder = __( '— Choose a field —', 'mapsemble' ); } $known = array(); ?> static_conditions = $this->read_condition_rows( $rows ); } if ( isset( $_POST['mapsemble_filter_mappings_present'] ) ) { $entries = isset( $_POST['mapsemble_filter_mappings'] ) && is_array( $_POST['mapsemble_filter_mappings'] ) ? wp_unslash( $_POST['mapsemble_filter_mappings'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each entry is sanitised field-by-field below. : array(); $config->filter_mappings = $this->read_mapping_entries( $entries ); } if ( isset( $_POST['mapsemble_sort_mappings_present'] ) ) { $entries = isset( $_POST['mapsemble_sort_mappings'] ) && is_array( $_POST['mapsemble_sort_mappings'] ) ? wp_unslash( $_POST['mapsemble_sort_mappings'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each entry is sanitised field-by-field below. : array(); $config->sort_mappings = $this->read_sort_entries( $entries ); } if ( isset( $_POST['mapsemble_properties_present'] ) ) { $rows = isset( $_POST['mapsemble_properties'] ) && is_array( $_POST['mapsemble_properties'] ) ? wp_unslash( $_POST['mapsemble_properties'] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each row is sanitised field-by-field below. : array(); $config->properties = $this->read_property_rows( $rows ); } // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Normalises submitted sort-mapping entries into stored rows. * * The `key` rode along as a hidden input carrying the remote entry's * config key, cached here so runtime matching never needs Mapsemble. * * @param array $entries Raw submitted entries, keyed by slug. * @return array Clean mappings keyed by slug. */ private function read_sort_entries( array $entries ): array { $clean = array(); foreach ( $entries as $slug => $entry ) { if ( ! is_array( $entry ) || ! is_string( $slug ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) ) { continue; } if ( isset( $entry['remove'] ) ) { continue; } $mapping = sanitize_text_field( (string) ( $entry['mapping'] ?? '' ) ); if ( '' === $mapping ) { continue; } $key = sanitize_text_field( (string) ( $entry['key'] ?? '' ) ); if ( '' === $key ) { $key = $slug; } if ( 'entropy' === $mapping ) { $clean[ $slug ] = array( 'key' => $key, 'type' => 'entropy', ); continue; } if ( ! in_array( $mapping, array( 'date', 'title', 'ID', 'meta' ), true ) ) { continue; } $row = array( 'key' => $key, 'type' => 'field', 'orderby' => $mapping, 'direction' => 'DESC' === strtoupper( sanitize_text_field( (string) ( $entry['direction'] ?? '' ) ) ) ? 'DESC' : 'ASC', ); if ( 'meta' === $mapping ) { $meta_key = sanitize_text_field( (string) ( $entry['meta_key'] ?? '' ) ); if ( '' === $meta_key ) { continue; } $row['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a stored row key, not query args. $row['numeric'] = isset( $entry['numeric'] ); } $clean[ $slug ] = $row; } return $clean; } /** * Normalises submitted property rows. * * @param array $rows Raw submitted rows. * @return array Clean rows. */ private function read_property_rows( array $rows ): array { $clean = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $field = $this->read_field_ref( $row ); if ( null === $field ) { continue; } $property = sanitize_text_field( (string) ( $row['property'] ?? '' ) ); if ( 'id' === $property || ! preg_match( '/^[A-Za-z0-9_-]+$/', $property ) ) { continue; } $clean[] = array( 'property' => $property, 'field' => $field, ); } return $clean; } /** * Normalises submitted filter-mapping entries. * * An operator that does not fit the chosen field's kind is coerced to * that kind's default rather than dropped: "picked a field, ignored the * operator" is the common path, and losing the mapping over it would be * a rough answer. The meta sanitizer still enforces validity on write. * * @param array $entries Raw submitted entries, keyed by slug. * @return array Clean mappings keyed by slug. */ private function read_mapping_entries( array $entries ): array { $clean = array(); foreach ( $entries as $slug => $entry ) { if ( ! is_array( $entry ) || ! is_string( $slug ) ) { continue; } if ( isset( $entry['remove'] ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) ) { continue; } $field = $this->read_field_ref( $entry ); if ( null === $field ) { continue; } $ref = FieldRef::parse( $field ); $operator = sanitize_text_field( (string) ( $entry['operator'] ?? '' ) ); if ( FieldRef::KIND_TAX === $ref['kind'] ) { if ( ! in_array( $operator, array( 'IN', 'NOT IN' ), true ) ) { $operator = 'IN'; } } elseif ( ! in_array( $operator, array( '=', '!=', 'CONTAINS', 'IN', 'NOT IN' ), true ) ) { $operator = '='; } $clean[ $slug ] = array( 'field' => $field, 'operator' => $operator, ); } return $clean; } /** * Normalises submitted condition rows. * * Row-level junk is dropped here for a friendly round trip; the meta * sanitizer revalidates everything on write regardless, so this is not * the security boundary. * * @param array $rows Raw submitted rows. * @return array Clean rows. */ private function read_condition_rows( array $rows ): array { $clean = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $field = $this->read_field_ref( $row ); if ( null === $field ) { continue; } $mode = sanitize_key( (string) ( $row['mode'] ?? '' ) ); if ( ! in_array( $mode, array( 'equals', 'exists', 'not_exists' ), true ) ) { continue; } $clean[] = array( 'field' => $field, 'mode' => $mode, 'value' => 'equals' === $mode ? sanitize_text_field( (string) ( $row['value'] ?? '' ) ) : '', ); } return $clean; } /** * Resolves a row's field reference, honouring the custom meta-key input. * * @param array $row Raw submitted row. */ private function read_field_ref( array $row ): ?string { $custom = sanitize_text_field( (string) ( $row['field_custom'] ?? '' ) ); $field = '' !== $custom ? 'meta:' . $custom : sanitize_text_field( (string) ( $row['field'] ?? '' ) ); return null !== FieldRef::parse( $field ) ? $field : null; } /** * Answers the field choices for a post type, for the selects' AJAX refresh. */ public function ajax_field_choices(): void { check_ajax_referer( 'mapsemble_field_choices', 'nonce' ); if ( ! current_user_can( Capabilities::EDIT_MAPS ) ) { wp_send_json_error( array(), 403 ); } $post_type = isset( $_POST['postType'] ) ? sanitize_key( wp_unslash( $_POST['postType'] ) ) : ''; wp_send_json_success( $this->field_choices( $post_type ) ); } /** * The field choices for one post type. * * @param string $post_type Post type. * @return array{taxonomies: array