repository = $repository; $this->api = $api; } /** * Hooks registration and the list table customisations. */ public function register(): void { add_action( 'init', array( $this, 'register_post_type' ) ); add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( $this, 'columns' ) ); add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( $this, 'render_column' ), 10, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_filter( 'post_row_actions', array( $this, 'row_actions' ), 10, 2 ); add_filter( 'enter_title_here', array( $this, 'title_placeholder' ), 10, 2 ); // Maps are never viewed on the front end; the button links nowhere useful. add_filter( 'post_type_labels_' . self::POST_TYPE, array( $this, 'labels' ) ); add_filter( 'wp_insert_post_data', array( $this, 'force_publish' ) ); add_filter( 'post_updated_messages', array( $this, 'updated_messages' ) ); add_filter( 'bulk_post_updated_messages', array( $this, 'bulk_updated_messages' ), 10, 2 ); } /** * Map wording for the notice after a save, in place of "Post published." * * Indices follow core's `$messages['post']`. There are no View or Preview * links — maps have no front end — and index 5 (revision restored) stays * empty, as maps do not keep revisions. * * @param array> $messages Messages per post type. * @return array> */ public function updated_messages( array $messages ): array { $messages[ self::POST_TYPE ] = array( 0 => '', // Unused. Messages start at index 1. 1 => __( 'Map saved.', 'mapsemble' ), 2 => __( 'Custom field updated.', 'mapsemble' ), 3 => __( 'Custom field deleted.', 'mapsemble' ), 4 => __( 'Map saved.', 'mapsemble' ), 5 => false, 6 => __( 'Map saved.', 'mapsemble' ), 7 => __( 'Map saved.', 'mapsemble' ), 8 => __( 'Map submitted.', 'mapsemble' ), 9 => __( 'Map scheduled.', 'mapsemble' ), 10 => __( 'Map draft saved.', 'mapsemble' ), ); return $messages; } /** * Map wording for the list's bulk-action notices ("3 maps moved to the Trash."). * * @param array> $messages Messages per post type. * @param array $counts Items affected, per action. * @return array> */ public function bulk_updated_messages( array $messages, array $counts ): array { // Normalised up front so each _n() gets a plain variable as its count: // make-pot skips a call whose count is a cast expression, and those // strings would never reach the POT. $counts = array_map( 'intval', $counts + array_fill_keys( array( 'updated', 'locked', 'deleted', 'trashed', 'untrashed' ), 0 ) ); $messages[ self::POST_TYPE ] = array( /* translators: %s: Number of maps. */ 'updated' => _n( '%s map updated.', '%s maps updated.', $counts['updated'], 'mapsemble' ), 'locked' => 1 === $counts['locked'] ? __( '1 map not updated, somebody is editing it.', 'mapsemble' ) /* translators: %s: Number of maps. */ : _n( '%s map not updated, somebody is editing it.', '%s maps not updated, somebody is editing them.', $counts['locked'], 'mapsemble' ), /* translators: %s: Number of maps. */ 'deleted' => _n( '%s map permanently deleted.', '%s maps permanently deleted.', $counts['deleted'], 'mapsemble' ), /* translators: %s: Number of maps. */ 'trashed' => _n( '%s map moved to the Trash.', '%s maps moved to the Trash.', $counts['trashed'], 'mapsemble' ), /* translators: %s: Number of maps. */ 'untrashed' => _n( '%s map restored from the Trash.', '%s maps restored from the Trash.', $counts['untrashed'], 'mapsemble' ), ); return $messages; } /** * Keeps saved maps at `publish`. * * A map has no front end, so draft and published mean the same thing to a * reader and differ only in whether the map works — which is a trap, not a * feature. Trashing stays available and is the way to take a map out of * service; `auto-draft` is left alone so that clicking Add New does not * publish an empty map, and revisions keep `inherit`. * * @param array $data Sanitised post data, about to be written. * @return array */ public function force_publish( array $data ): array { if ( self::POST_TYPE !== ( $data['post_type'] ?? '' ) ) { return $data; } if ( in_array( $data['post_status'] ?? '', array( 'auto-draft', 'trash', 'inherit' ), true ) ) { return $data; } $data['post_status'] = 'publish'; return $data; } /** * Registers the post type. */ public function register_post_type(): void { register_post_type( self::POST_TYPE, array( 'labels' => array( 'name' => _x( 'Built from this site', 'admin menu / list title', 'mapsemble' ), 'singular_name' => __( 'Site-fed map', 'mapsemble' ), 'menu_name' => __( 'Mapsemble', 'mapsemble' ), 'name_admin_bar' => __( 'Site-fed map', 'mapsemble' ), 'all_items' => _x( 'Built from this site', 'admin menu / list title', 'mapsemble' ), // The list's button reads `add_new` on older WordPress and // `add_new_item` on newer; both say the same. 'add_new' => __( 'Build a map from this site', 'mapsemble' ), 'add_new_item' => __( 'Build a map from this site', 'mapsemble' ), 'edit_item' => __( 'Edit site-fed map', 'mapsemble' ), 'new_item' => __( 'New site-fed map', 'mapsemble' ), 'search_items' => __( 'Search site-fed maps', 'mapsemble' ), 'not_found' => __( 'No maps are built from this site yet. To embed a map hosted on Mapsemble, just add the Mapsemble Map block — nothing is needed here.', 'mapsemble' ), 'not_found_in_trash' => __( 'No maps found in Trash.', 'mapsemble' ), ), 'public' => false, 'publicly_queryable' => false, 'exclude_from_search' => true, 'show_ui' => true, 'show_in_menu' => Menu::SLUG, 'show_in_rest' => false, 'show_in_nav_menus' => false, 'hierarchical' => false, 'has_archive' => false, 'rewrite' => false, 'query_var' => false, 'supports' => array( 'title' ), 'capability_type' => array( 'mapsemble_map', 'mapsemble_maps' ), 'map_meta_cap' => true, 'delete_with_user' => false, ) ); $this->register_meta(); } /** * Registers the configuration meta. * * All of it is `show_in_rest => false` and `single`. The auth callback is * the post type's own edit capability rather than the default * `edit_posts`, so a contributor on the *content* post types cannot write * a map's configuration through `update_post_meta()`. */ private function register_meta(): void { $string_meta = array( self::META_MAP_ID => 'sanitize_text_field', self::META_MODE => array( self::class, 'sanitize_mode' ), self::META_POST_TYPE => 'sanitize_key', self::META_LOCATION_SOURCE => 'sanitize_key', self::META_ACF_FIELD => 'sanitize_text_field', // sanitize_text_field, NOT sanitize_key: meta keys are // case-sensitive and may start with an underscore. self::META_LAT_META_KEY => 'sanitize_text_field', self::META_LNG_META_KEY => 'sanitize_text_field', self::META_STATIC_CONDITIONS => array( self::class, 'sanitize_static_conditions' ), self::META_FILTER_MAPPINGS => array( self::class, 'sanitize_filter_mappings' ), self::META_SORT_MAPPINGS => array( self::class, 'sanitize_sort_mappings' ), self::META_PROPERTIES => array( self::class, 'sanitize_properties' ), ); foreach ( $string_meta as $key => $sanitize ) { register_post_meta( self::POST_TYPE, $key, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => false, 'sanitize_callback' => $sanitize, 'auth_callback' => static function ( bool $allowed, string $meta_key, int $post_id ): bool { return current_user_can( 'edit_post', $post_id ); }, ) ); } } /** * Normalises the mode meta to one of the two known values. * * Defaults to hosted, which is the safe direction: an unrecognised value * that fell through to `wordpress` would open the public feed for a map * whose configuration we could not read. * * @param mixed $value Raw meta value. */ public static function sanitize_mode( $value ): string { return MapConfig::MODE_WORDPRESS === $value ? MapConfig::MODE_WORDPRESS : MapConfig::MODE_HOSTED; } /** * Operators the filter-mapping sanitizer accepts, per field kind. * * Taxonomy conditions translate to `tax_query` operators; meta conditions * to `meta_query` compares (`CONTAINS` becomes `LIKE`). Drupal's * STARTS_WITH / ENDS_WITH are deliberately absent — `meta_query` LIKE is * always two-sided, and a raw SQL clause is not worth those two operators. */ private const TAX_OPERATORS = array( 'IN', 'NOT IN' ); private const META_OPERATORS = array( '=', '!=', 'CONTAINS', 'IN', 'NOT IN' ); /** * Sanitizes the always-on filter rows. * * Row shape: `{field: "tax:…"|"meta:…", mode: equals|exists|not_exists, * value: string}`. Bad rows are dropped rather than failing the whole * value, so one stale row cannot wipe a map's other conditions. * * @param mixed $value Raw meta value (JSON string). */ public static function sanitize_static_conditions( $value ): string { $rows = self::decode_list( $value ); if ( null === $rows ) { return ''; } $clean = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || null === FieldRef::parse( $row['field'] ?? null ) ) { continue; } $mode = $row['mode'] ?? ''; if ( ! in_array( $mode, array( 'equals', 'exists', 'not_exists' ), true ) ) { continue; } $clean[] = array( 'field' => (string) $row['field'], 'mode' => (string) $mode, // Only `equals` carries a value; the existence modes ignore // it, and storing it anyway would resurrect it as a surprise // when the mode changes back. 'value' => 'equals' === $mode ? (string) ( $row['value'] ?? '' ) : '', ); } return array() === $clean ? '' : (string) wp_json_encode( $clean ); } /** * Sanitizes the remote-filter → local-field mappings. * * Object keyed by remote filter slug; entry shape `{field, operator}`. * The operator must fit the field kind (see the operator constants). * * @param mixed $value Raw meta value (JSON string). */ public static function sanitize_filter_mappings( $value ): string { $entries = self::decode_map( $value ); if ( null === $entries ) { return ''; } $clean = array(); foreach ( $entries as $slug => $entry ) { if ( ! is_string( $slug ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) || ! is_array( $entry ) ) { continue; } $ref = FieldRef::parse( $entry['field'] ?? null ); if ( null === $ref ) { continue; } $operators = FieldRef::KIND_TAX === $ref['kind'] ? self::TAX_OPERATORS : self::META_OPERATORS; $operator = $entry['operator'] ?? ''; if ( ! in_array( $operator, $operators, true ) ) { continue; } $clean[ $slug ] = array( 'field' => (string) $entry['field'], 'operator' => (string) $operator, ); } return array() === $clean ? '' : (string) wp_json_encode( $clean ); } /** * Sanitizes the remote-orderBy → local-sort mappings. * * Object keyed by remote slug. Rows are either `{key, type: "entropy"}` * or `{key, type: "field", orderby: date|title|ID|meta, direction, * [meta_key], [numeric]}`. * * @param mixed $value Raw meta value (JSON string). */ public static function sanitize_sort_mappings( $value ): string { $entries = self::decode_map( $value ); if ( null === $entries ) { return ''; } $clean = array(); foreach ( $entries as $slug => $entry ) { if ( ! is_string( $slug ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) || ! is_array( $entry ) ) { continue; } // The remote entry's config.key, cached at save time so runtime // matching of `?orderBy=` never needs a Mapsemble call. $key = (string) ( $entry['key'] ?? '' ); if ( '' === $key ) { $key = $slug; } if ( 'entropy' === ( $entry['type'] ?? '' ) ) { $clean[ $slug ] = array( 'key' => $key, 'type' => 'entropy', ); continue; } if ( 'field' !== ( $entry['type'] ?? '' ) ) { continue; } $orderby = (string) ( $entry['orderby'] ?? '' ); if ( ! in_array( $orderby, array( 'date', 'title', 'ID', 'meta' ), true ) ) { continue; } $row = array( 'key' => $key, 'type' => 'field', 'orderby' => $orderby, 'direction' => 'DESC' === strtoupper( (string) ( $entry['direction'] ?? '' ) ) ? 'DESC' : 'ASC', ); if ( 'meta' === $orderby ) { $meta_key = (string) ( $entry['meta_key'] ?? '' ); if ( '' === $meta_key || 1 === preg_match( '/\s/', $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'] = (bool) ( $entry['numeric'] ?? false ); } $clean[ $slug ] = $row; } return array() === $clean ? '' : (string) wp_json_encode( $clean ); } /** * Sanitizes the custom GeoJSON property rows. * * Row shape `{property, field}`. `id` is reserved — it is the feed's own * feature key and letting a row shadow it would corrupt the wire format. * * @param mixed $value Raw meta value (JSON string). */ public static function sanitize_properties( $value ): string { $rows = self::decode_list( $value ); if ( null === $rows ) { return ''; } $clean = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || null === FieldRef::parse( $row['field'] ?? null ) ) { continue; } $property = (string) ( $row['property'] ?? '' ); if ( 'id' === $property || ! preg_match( '/^[A-Za-z0-9_-]+$/', $property ) ) { continue; } $clean[] = array( 'property' => $property, 'field' => (string) $row['field'], ); } return array() === $clean ? '' : (string) wp_json_encode( $clean ); } /** * Decodes a JSON value expected to be a list. * * @param mixed $value Raw meta value. * @return array|null Null when not a JSON list at all. */ private static function decode_list( $value ): ?array { if ( ! is_string( $value ) ) { return null; } $decoded = json_decode( $value, true ); // Not array_is_list(): its WordPress polyfill only arrived in 6.5, // and this plugin supports 6.3 on PHP 8.0. return is_array( $decoded ) && array_values( $decoded ) === $decoded ? $decoded : null; } /** * Decodes a JSON value expected to be a string-keyed object. * * @param mixed $value Raw meta value. * @return array|null Null when not a JSON object at all. */ private static function decode_map( $value ): ?array { if ( ! is_string( $value ) ) { return null; } $decoded = json_decode( $value, true ); // An empty JSON array decodes identically to an empty object; accept it. // array_values() comparison instead of array_is_list(): see decode_list(). return is_array( $decoded ) && ( array() === $decoded || array_values( $decoded ) !== $decoded ) ? $decoded : null; } /** * Drops the "View Map" labels — maps have no front end. * * @param object $labels Generated post type labels. */ public function labels( object $labels ): object { $labels->view_item = ''; $labels->view_items = ''; return $labels; } /** * Replaces the title placeholder with what the title actually is. * * @param string $text Default placeholder. * @param \WP_Post $post The post being edited. */ public function title_placeholder( string $text, \WP_Post $post ): string { return self::POST_TYPE === $post->post_type ? __( 'Map name (for your reference)', 'mapsemble' ) : $text; } /** * Defines the list table columns. * * @param array $columns Default columns. * @return array */ public function columns( array $columns ): array { return array( 'cb' => $columns['cb'] ?? '', 'title' => __( 'Map', 'mapsemble' ), 'mode' => __( 'Data', 'mapsemble' ), 'post_type' => __( 'Post type', 'mapsemble' ), 'map_id' => __( 'Mapsemble map', 'mapsemble' ), 'shortcode' => __( 'Shortcode', 'mapsemble' ), 'date' => $columns['date'] ?? __( 'Date', 'mapsemble' ), ); } /** * Renders one custom column. * * @param string $column Column key. * @param int $post_id Post id. */ public function render_column( string $column, int $post_id ): void { $config = $this->repository->get( $post_id ); if ( null === $config ) { return; } switch ( $column ) { case 'mode': echo esc_html( $this->mode_label( $config ) ); break; case 'post_type': // Meaningless for a hosted map, and printing a retained value // there would read as "this is what the map shows". echo $config->is_hosted() ? '—' : esc_html( $config->post_type ); break; case 'map_id': // The full id is on hover; the cell only has to tell maps apart. echo '' !== $config->map_id ? '' . esc_html( MapChoices::short_id( $config->map_id ) ) . '' : '' . esc_html__( 'Not connected', 'mapsemble' ) . ''; break; case 'shortcode': $this->render_shortcodes( $config ); break; } } /** * The Shortcode column: each form of the shortcode, with a Copy button. * * The embed-first `map="…"` form leads, as in the readme; it needs a * bound map. The `id="…"` form always works. The shortcode stays plain * text outside its button, so it can be selected by hand; the buttons * are hidden until admin-map-list.js reveals them, so there is no dead * button without the script. The per-cell status shows the result; the * script announces it through wp.a11y. * * @param MapConfig $config The map. */ private function render_shortcodes( MapConfig $config ): void { $shortcodes = array(); if ( '' !== $config->map_id ) { $shortcodes[] = '[mapsemble map="' . $config->map_id . '"]'; } $shortcodes[] = '[mapsemble id="' . $config->id . '"]'; $lines = array(); foreach ( $shortcodes as $shortcode ) { $lines[] = sprintf( '%1$s ', esc_html( $shortcode ), esc_attr( $shortcode ), esc_html_x( 'Copy', 'button: copy a shortcode', 'mapsemble' ), /* translators: %s: a shortcode. Screen readers read it after the visible "Copy" of the button. */ esc_html( sprintf( __( 'shortcode %s', 'mapsemble' ), $shortcode ) ) ); } echo implode( '
', $lines ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. echo ' '; } /** * Enqueues the click-to-copy script on this post type's list only. * * @param string $hook_suffix Current admin page. */ public function enqueue( string $hook_suffix ): void { if ( 'edit.php' !== $hook_suffix ) { return; } $screen = get_current_screen(); if ( null === $screen || self::POST_TYPE !== $screen->post_type ) { return; } wp_enqueue_script( 'mapsemble-admin-map-list', plugins_url( 'assets/js/admin-map-list.js', MAPSEMBLE_PLUGIN_FILE ), array( 'wp-a11y' ), MAPSEMBLE_VERSION, true ); wp_localize_script( 'mapsemble-admin-map-list', 'mapsembleAdminMapList', array( 'copiedText' => __( 'Copied', 'mapsemble' ), /* translators: %s: the shortcode that was copied. Announced to screen readers. */ 'copiedAnnounce' => __( 'Copied %s', 'mapsemble' ), /* translators: shown when the browser would not copy; the shortcode text is selected instead. */ 'selectText' => __( 'Selected — press Ctrl+C (⌘C on a Mac) to copy', 'mapsemble' ), ) ); } /** * The Data column's text for one map. * * The "(Pro)" marker is only ever added when the account's plan has been * read and says so. Nothing is blocked by it — the map works either way, * carrying Mapsemble's own upgrade notice — and when the plan is unknown * it says nothing at all, because telling a Pro customer they need Pro is * worse than silence. * * @param MapConfig $config The map. */ private function mode_label( MapConfig $config ): string { if ( $config->is_hosted() ) { // Distinct copy so administrators can find cleanup candidates: // hosted maps no longer need a configuration post at all. return __( 'From Mapsemble (legacy)', 'mapsemble' ); } return false === $this->api->remote_source_allowed() ? __( 'From this site (Pro)', 'mapsemble' ) : __( 'From this site', 'mapsemble' ); } /** * Adds the "Edit data" row action to hosted maps. * * @param array $actions Existing row actions. * @param \WP_Post $post The row's post. * @return array */ public function row_actions( array $actions, \WP_Post $post ): array { if ( self::POST_TYPE !== $post->post_type || ! current_user_can( Capabilities::EDIT_MAP_DATA ) ) { return $actions; } $config = $this->repository->get( (int) $post->ID ); if ( null === $config || ! $config->is_hosted() || '' === $config->map_id ) { return $actions; } $actions['mapsemble_data'] = sprintf( '%s', esc_url( DataEditorPage::editor_url( $config->map_id ) ), esc_html__( 'Edit data', 'mapsemble' ) ); return $actions; } }