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

', esc_html__( 'Mapsemble could not be reached, so the map\'s sort list may be incomplete. Saved mappings are shown and will not be lost by saving.', 'mapsemble' ) ); } $orphans = array_diff_key( $config->sort_mappings, $catalog ); if ( array() === $catalog && array() === $orphans ) { printf( '

%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; } ?>

$entry ) : ?> sort_mappings[ $slug ] ?? null ) ? $config->sort_mappings[ $slug ] : array(); $key = (string) ( $entry['config']['key'] ?? $slug ); ?> render_sort_mapping_cells( (string) $slug, $mapping ); ?> $mapping ) : ?> render_sort_mapping_cells( (string) $slug, $mapping ); ?>

__( '— Do not map —', 'mapsemble' ), 'entropy' => __( 'Entropy (spread across the map)', 'mapsemble' ), 'date' => __( 'Publication date', 'mapsemble' ), 'title' => __( 'Title', 'mapsemble' ), 'ID' => __( 'Post ID', 'mapsemble' ), 'meta' => __( 'Custom field…', 'mapsemble' ), ); ?> repository->get( (int) $post->ID ) ?? new MapConfig( (int) $post->ID, (string) $post->post_title, '' ); $choices = $this->field_choices( $config->post_type ); ?>

properties as $i => $row ) : ?>
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

', esc_html__( 'Mapsemble could not be reached, so the map\'s filter list may be incomplete. Saved mappings are shown and will not be lost by saving.', 'mapsemble' ) ); } $choices = $this->field_choices( $config->post_type ); $orphans = array_diff_key( $config->filter_mappings, $catalog ); if ( array() === $catalog && array() === $orphans ) { printf( '

%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; } ?>

$entry ) : ?> filter_mappings[ $slug ] ?? null ) ? $config->filter_mappings[ $slug ] : array(); ?> $mapping ) : ?>
() 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'] ?? '' ) ); ?>
__( 'is one of (taxonomy)', 'mapsemble' ), 'NOT IN' => __( 'is none of (taxonomy)', 'mapsemble' ), '=' => __( 'equals (custom field)', 'mapsemble' ), '!=' => __( 'does not equal (custom field)', 'mapsemble' ), 'CONTAINS' => __( 'contains (custom field)', 'mapsemble' ), ); ?> repository->get( (int) $post->ID ) ?? new MapConfig( (int) $post->ID, (string) $post->post_title, '' ); $choices = $this->field_choices( $config->post_type ); $modes = array( 'equals' => __( 'is (one of)', 'mapsemble' ), 'exists' => __( 'has any value', 'mapsemble' ), 'not_exists' => __( 'has no value', 'mapsemble' ), ); ?>

static_conditions as $i => $row ) : ?>
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, metaKeys: string[]} */ private function field_choices( string $post_type ): array { $taxonomies = array(); foreach ( get_object_taxonomies( $post_type, 'objects' ) as $taxonomy ) { if ( ! $taxonomy->public && ! $taxonomy->show_ui ) { continue; } $taxonomies[ 'tax:' . $taxonomy->name ] = $taxonomy->label . ' (' . $taxonomy->name . ')'; } return array( 'taxonomies' => $taxonomies, 'metaKeys' => $this->meta_keys_for( $post_type ), ); } /** * Distinct non-hidden meta keys in use on a post type. * * Capped, and skipping `_`-prefixed keys: those are protected plumbing * (including this plugin's own), not fields an administrator filters by. * The custom text input covers the exceptions. * * @param string $post_type Post type. * @return string[] */ private function meta_keys_for( string $post_type ): array { global $wpdb; if ( '' === $post_type || ! post_type_exists( $post_type ) ) { return array(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a capped DISTINCT scan behind an admin screen; core offers no API for it. $keys = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.meta_key FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s AND pm.meta_key NOT LIKE %s ORDER BY pm.meta_key LIMIT 200", $post_type, $wpdb->esc_like( '_' ) . '%' ) ); return array_map( 'strval', $keys ); } }