api = $api; $this->repository = $repository; $this->coordinates = $coordinates; $this->resolver = $resolver; $this->feed_boxes = $feed_boxes ?? new FeedSettingsBoxes( $api, $repository ); } /** * Hooks the screen up. */ public function register(): void { add_action( 'edit_form_after_title', array( $this, 'render_explainer' ) ); add_action( 'add_meta_boxes_' . MapPostType::POST_TYPE, array( $this, 'add_meta_boxes' ) ); add_action( 'save_post_' . MapPostType::POST_TYPE, array( $this, 'save' ), 10, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'admin_notices', array( $this, 'render_notice' ) ); add_action( 'wp_ajax_mapsemble_create_map', array( $this, 'ajax_create_map' ) ); add_action( 'admin_post_mapsemble_reindex', array( $this, 'handle_reindex' ) ); $this->feed_boxes->register(); } /** * Renders the collapsed "How this works" explainer above the boxes. * * `edit_form_after_title` rather than a fourth meta box: it stays on top * regardless of box reordering, and a plain
needs no JS and no * screen-option persistence. Copy adapted from the Drupal module's * buildHowThisWorks(), rewritten for the WordPress model. * * @param WP_Post $post The post being edited. */ public function render_explainer( WP_Post $post ): void { if ( MapPostType::POST_TYPE !== $post->post_type ) { return; } ?>

[mapsemble id="…"]', '[mapsemble map="…"]' ); ?>

mapsemble_location_sources', 'mapsemble_post_coordinates', 'mapsemble_widget_settings' ); ?>

ID, MapPostType::META_MODE, true ); if ( MapConfig::MODE_HOSTED === MapPostType::sanitize_mode( $raw_mode ) && '' !== $raw_mode ) { add_meta_box( 'mapsemble_map_legacy', __( 'This map\'s locations live on Mapsemble', 'mapsemble' ), array( $this, 'render_legacy_box' ), MapPostType::POST_TYPE, 'normal', 'high' ); return; } add_meta_box( 'mapsemble_map_source', __( 'WordPress content', 'mapsemble' ), array( $this, 'render_source_box' ), MapPostType::POST_TYPE, 'normal', 'default' ); $this->feed_boxes->add_boxes(); } /** * Renders the legacy-hosted notice box. * * @param WP_Post $post The map being edited. */ public function render_legacy_box( WP_Post $post ): void { $config = $this->config_for( $post ); ?>

[mapsemble map="map_id ); ?>"]

map_id && current_user_can( Capabilities::EDIT_MAP_DATA ) ) : ?>

post_type ) { return; } wp_enqueue_script( 'mapsemble-admin-map', plugins_url( 'assets/js/admin-map.js', MAPSEMBLE_PLUGIN_FILE ), array( 'wp-a11y', 'wp-i18n' ), MAPSEMBLE_VERSION, true ); wp_set_script_translations( 'mapsemble-admin-map', 'mapsemble', MAPSEMBLE_PLUGIN_DIR . '/languages' ); wp_enqueue_style( 'mapsemble-admin-map', plugins_url( 'assets/css/admin-map.css', MAPSEMBLE_PLUGIN_FILE ), array(), MAPSEMBLE_VERSION ); wp_localize_script( 'mapsemble-admin-map', 'mapsembleAdminMap', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'mapsemble_create_map' ), 'fieldChoicesNonce' => wp_create_nonce( 'mapsemble_field_choices' ), 'creatingText' => __( 'Creating…', 'mapsemble' ), 'genericError' => __( 'Could not create the map. Please try again.', 'mapsemble' ), 'taxonomiesLabel' => __( 'Taxonomies', 'mapsemble' ), 'metaKeysLabel' => __( 'Custom fields', 'mapsemble' ), ) ); } /** * Renders the remote-map binding box. * * @param WP_Post $post The map being edited. */ public function render_binding_box( WP_Post $post ): void { wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); $config = $this->config_for( $post ); $remote_maps = $this->api->get_remote_maps(); $error = $this->api->get_last_error(); // A saved binding to a map that is not in the listing still has to be // selectable, or saving this screen would silently rebind the map. $known = array() !== $remote_maps && isset( $remote_maps[ $config->map_id ] ); ?>

api->has_credentials() ) : ?>

' . esc_html__( 'Add them', 'mapsemble' ) . '' ); ?>

self::MAP_FILTER_THRESHOLD ) : ?>

>


config_for( $post ); $is_new = '' === $config->map_id && self::is_unsaved( $post ); if ( ! $config->is_hosted() && false === $this->api->remote_source_allowed() ) { // Not a gate. Mapsemble deliberately lets a free-plan map with a // remote data source render and puts the upgrade notice over the // embed instead. Silent when the plan could not be read: a wrong // "requires Pro" shown to a Pro customer is worse than nothing. printf( '

%s

', esc_html( $this->pro_plan_notice() ) ); } if ( '' !== $config->map_id && ! $is_new ) { $remote = $this->api->get_remote_map( $config->map_id ); if ( array() === $remote ) { printf( '

%s

', esc_html__( 'This map no longer exists on the connected Mapsemble account. Choose another map above, or create a new one. Everything on this screen still saves.', 'mapsemble' ) ); } elseif ( null === $remote ) { printf( '

%s

', esc_html__( 'Mapsemble could not be reached, so this map could not be checked. Everything on this screen still saves.', 'mapsemble' ) ); } else { $this->render_drift_warning( $config, $remote ); } } } /** * Warns when Mapsemble and this site disagree about the data source. * * There is no re-apply button, on purpose: saving this screen patches * Mapsemble on every save, so saving *is* the re-apply. A dedicated button * would have been a second code path doing the same thing. * * @param MapConfig $config The saved map. * @param array $remote The remote map, or [] when unknown. */ private function render_drift_warning( MapConfig $config, array $remote ): void { $remote_mode = (string) ( $remote['dataSource']['mode'] ?? '' ); $expected = $config->is_hosted() ? 'local' : 'remote'; if ( '' === $remote_mode || $remote_mode === $expected ) { return; } // One whole sentence per direction: the API's "local" / "remote" are // Mapsemble's words, not the user's, and do not belong in the copy. if ( 'local' === $remote_mode ) { $message = __( 'Mapsemble says this map takes its data from Mapsemble itself, but this site is set to supply its locations. Update this map to re-apply this site\'s setting to Mapsemble.', 'mapsemble' ); } elseif ( 'remote' === $remote_mode ) { $message = __( 'Mapsemble says this map takes its data from a feed, but this site is set to keep its locations on Mapsemble. Update this map to re-apply this site\'s setting to Mapsemble.', 'mapsemble' ); } else { $message = __( 'Mapsemble reports a different data source for this map than the one set on this site. Update this map to re-apply this site\'s setting to Mapsemble.', 'mapsemble' ); } printf( '

%s

', esc_html( $message ) ); } /** * The single thing there is to say about a free plan and site-fed maps. * * Worded around what actually happens, because nothing here is blocked: * the map is set up, saved and served exactly as on a paid plan, and the * only difference a visitor meets is a notice over the embed. Copy * implying the feature is unavailable would simply be untrue. */ private function pro_plan_notice(): string { return __( 'Building a map from this site\'s content needs the Pro plan. You can set it up and use it now — the map will work, and visitors will see an upgrade notice over it until the account is upgraded.', 'mapsemble' ); } /** * Renders the WordPress content source box. * * @param WP_Post $post The map being edited. */ public function render_source_box( WP_Post $post ): void { $config = $this->config_for( $post ); $post_types = get_post_types( array( 'public' => true ), 'objects' ); unset( $post_types['attachment'] ); $this->render_served_count( $post, $config ); ?>

is_hosted() ) { return; } $count = ( new FeedQuery() )->served_count( $config ); if ( 0 === $count && array() !== $config->static_conditions ) { $message = __( 'This map currently serves no posts — check the always-on filters below.', 'mapsemble' ); } else { $message = sprintf( /* translators: %s: number of posts the map's feed serves. */ _n( 'This map currently serves %s post.', 'This map currently serves %s posts.', $count, 'mapsemble' ), number_format_i18n( $count ) ); } printf( '

%s

', esc_html( $message ) ); } /** * Saves the screen, ordering the Mapsemble update ahead of the meta write. * * Mapsemble is the commit point. Patching first makes the worst case the * harmless direction — Mapsemble updated, this site not, fixed by saving * again — rather than a live map whose widget fetches a feed that has just * started refusing it. * * WordPress differs from the Drupal module here in one respect worth * knowing: there is no pre-commit hook for post meta, so the post row * (the title) is already written by the time this runs and will persist * even when the patch fails. The configuration is not, which is the part * that matters. * * @param int $post_id The map's post id. * @param WP_Post $post The map post. */ public function save( int $post_id, WP_Post $post ): void { if ( ! isset( $_POST[ self::NONCE_FIELD ] ) ) { // Quick Edit, bulk edit and programmatic saves never render the // meta boxes, so they must not be read as an empty submission. return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) ), self::NONCE_ACTION ) ) { return; } if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } // Read before anything is written: this is the pre-save state, and the // fallbacks below depend on it. $stored = $this->repository->get( $post_id ) ?? new MapConfig( $post_id, '', '' ); $manual = isset( $_POST['mapsemble_map_id_manual'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['mapsemble_map_id_manual'] ) ) ) : ''; $map_id = '' !== $manual ? $manual : ( isset( $_POST['mapsemble_map_id'] ) ? sanitize_text_field( wp_unslash( $_POST['mapsemble_map_id'] ) ) : '' ); // '' is "not connected", a choice in its own right. Anything else has // to be a map id before it goes anywhere near a Mapsemble URL; a typo // keeps the binding the map had rather than silently dropping it. if ( '' !== $map_id && ! MapId::is_valid( $map_id ) ) { $this->set_notice( $post_id, 'error', sprintf( /* translators: %s: the map ID as entered. */ __( '“%s” is not a Mapsemble map ID, so the map\'s connection was left unchanged.', 'mapsemble' ), $map_id ) ); $map_id = $stored->map_id; } // Config posts are source-only: a new one (no mode meta at all) is // written as WordPress explicitly — never left to the sanitizer's // fail-closed hosted default, which now marks only legacy posts. // A stored hosted post stays hosted unless the explicit, one-way // convert checkbox says otherwise; raw meta written outside this // screen still fails closed via sanitize_mode() on read. $raw_mode = (string) get_post_meta( $post_id, MapPostType::META_MODE, true ); $mode = '' === $raw_mode ? MapConfig::MODE_WORDPRESS : MapPostType::sanitize_mode( $raw_mode ); if ( MapConfig::MODE_HOSTED === $mode && isset( $_POST['mapsemble_convert_to_wordpress'] ) ) { $mode = MapConfig::MODE_WORDPRESS; } $config = new MapConfig( $post_id, (string) $post->post_title, $map_id, $mode, $stored->post_type, $stored->location_source, $stored->acf_field, $stored->static_conditions, $stored->filter_mappings, $stored->sort_mappings, $stored->properties, $stored->lat_meta_key, $stored->lng_meta_key ); // Source fields are only honoured for a site-fed map. A hosted map // keeps whatever it had — that is what makes switching back lossless — // and a forged submission cannot write them either. if ( MapConfig::MODE_WORDPRESS === $mode ) { $this->read_source_fields( $config ); $this->feed_boxes->read_fields( $config ); } if ( ! $this->mirror_to_mapsemble( $config ) ) { return; } $this->repository->write_meta( $config ); if ( ! $config->is_hosted() ) { // Saved as entered — the term may be about to be created — but a // slug no term has matches nothing, and nothing else would say so. $unknown = ( new FeedQuery() )->unknown_term_slugs( $config ); if ( array() !== $unknown ) { $this->set_notice( $config->id, 'warning', sprintf( /* translators: %s: term slugs, separated by the locale's list separator. */ __( 'These terms do not exist, so no post can match them: %s. The filter was saved as entered.', 'mapsemble' ), implode( wp_get_list_item_separator(), $unknown ) ) ); } } } /** * Reads the WordPress content fields onto a config, validating them. * * @param MapConfig $config The config being built. Modified in place. */ private function read_source_fields( MapConfig $config ): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- the caller verified the nonce. $post_type = isset( $_POST['mapsemble_post_type'] ) ? sanitize_key( wp_unslash( $_POST['mapsemble_post_type'] ) ) : ''; $source = isset( $_POST['mapsemble_location_source'] ) ? sanitize_key( wp_unslash( $_POST['mapsemble_location_source'] ) ) : ''; $acf_field = isset( $_POST['acf_field'] ) ? sanitize_text_field( wp_unslash( $_POST['acf_field'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing $public_types = get_post_types( array( 'public' => true ) ); if ( isset( $public_types[ $post_type ] ) ) { $config->post_type = $post_type; } if ( array_key_exists( $source, $this->get_location_sources() ) ) { $config->location_source = $source; } if ( MapConfig::SOURCE_ACF === $config->location_source ) { $config->acf_field = $acf_field; } if ( MapConfig::SOURCE_META_KEYS === $config->location_source ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- the caller verified the nonce. // Absent fields keep the stored keys — a submission that never // rendered them (Quick Edit-adjacent paths) must not wipe them. if ( isset( $_POST['mapsemble_lat_meta_key'] ) ) { $config->lat_meta_key = sanitize_text_field( wp_unslash( $_POST['mapsemble_lat_meta_key'] ) ); } if ( isset( $_POST['mapsemble_lng_meta_key'] ) ) { $config->lng_meta_key = sanitize_text_field( wp_unslash( $_POST['mapsemble_lng_meta_key'] ) ); } // phpcs:enable WordPress.Security.NonceVerification.Missing } } /** * Points the remote map's data source at this map's mode. * * Patched on every save, not only when the mode changed. Re-asserting the * same values is one request beside the reads this screen already makes, * and it is the only thing that repairs drift: a map switched from the * Mapsemble builder, or left half-applied by an earlier failure, has no * other route back. It also re-asserts the feed URL, which changes without * the mode changing whenever the site moves domain. * * @param MapConfig $config The config about to be written. * @return bool True when the write may proceed. */ private function mirror_to_mapsemble( MapConfig $config ): bool { if ( '' === $config->map_id ) { // Not connected to anything yet — there is nothing to mirror, and // the map is inert until it is. return true; } // A map we could not read and a map Mapsemble says is gone are both // maps we must not reconfigure blind, so this catches both failure // answers. The radio was disabled in either case, so the mode cannot // have changed; refusing the save instead would block unrelated edits // for as long as the binding is stale or Mapsemble is down. if ( ! $this->api->get_remote_map( $config->map_id ) ) { return true; } $ok = $config->is_hosted() ? $this->api->patch_data_source( $config->map_id, 'local' ) : $this->api->patch_data_source( $config->map_id, 'remote', $this->api->public_rest_url( 'mapsemble/v1/features' ) ); if ( ! $ok ) { $this->set_notice( $config->id, 'error', __( 'Could not update the data source on Mapsemble, so this map\'s settings were not changed. Check that Mapsemble is reachable and save again.', 'mapsemble' ) . ' ' . $this->api->get_last_error() ); return false; } // The plan is not repeated here: the binding box says it inline on // every render, so a save notice would only say it twice. if ( ! $config->is_hosted() && $this->api->looks_like_local_host() ) { $this->set_notice( $config->id, 'warning', __( 'This site runs on a local domain the Mapsemble service cannot reach, so this map will stay empty. Expose the site through a tunnel and define MAPSEMBLE_REMOTE_BASE_URL in wp-config.php, or take the map\'s data from Mapsemble instead.', 'mapsemble' ) ); } return true; } /** * Creates a map on Mapsemble and hands its id back to the screen. */ public function ajax_create_map(): void { check_ajax_referer( 'mapsemble_create_map', 'nonce' ); if ( ! current_user_can( Capabilities::EDIT_MAPS ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to create maps.', 'mapsemble' ) ), 403 ); } $label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : ''; $map = $this->api->create_map( $label ); if ( array() === $map ) { wp_send_json_error( array( 'message' => $this->api->get_last_error() ) ); } wp_send_json_success( array( 'id' => (string) $map['id'], 'label' => (string) ( $map['label'] ?? $label ), ) ); } /** * Rebuilds stored coordinates for a map's post type, 200 posts at a time. * * Self-redirecting rather than batched over AJAX: it is a rare, * administrator-initiated operation, and a redirect loop survives a slow * host in a way a single long request does not. */ public function handle_reindex(): void { $map_id = isset( $_REQUEST['map'] ) ? (int) $_REQUEST['map'] : 0; check_admin_referer( 'mapsemble_reindex_' . $map_id ); if ( ! current_user_can( 'edit_post', $map_id ) ) { wp_die( esc_html__( 'You are not allowed to reindex this map.', 'mapsemble' ) ); } $config = $this->repository->get( $map_id ); if ( null === $config ) { wp_safe_redirect( MapPostType::list_url() ); exit; } $offset = isset( $_REQUEST['offset'] ) ? max( 0, (int) $_REQUEST['offset'] ) : 0; $batch = 200; $query = new WP_Query( array( 'post_type' => $config->post_type, 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), 'fields' => 'ids', 'posts_per_page' => $batch, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, ) ); foreach ( array_map( 'intval', $query->posts ) as $post_id ) { $this->coordinates->write( $post_id, $this->resolver->resolve( $post_id, $config ) ); } $done = $offset + count( $query->posts ); if ( count( $query->posts ) === $batch ) { wp_safe_redirect( $this->reindex_url( $map_id, $done ) ); exit; } $this->set_notice( $map_id, 'success', sprintf( /* translators: %d: number of posts processed. */ _n( 'Rebuilt the location for %d post.', 'Rebuilt the locations for %d posts.', $done, 'mapsemble' ), $done ) ); wp_safe_redirect( get_edit_post_link( $map_id, 'redirect' ) ); exit; } /** * Builds the nonce-protected reindex URL. * * @param int $map_id Map post id. * @param int $offset Post offset to resume from. */ private function reindex_url( int $map_id, int $offset = 0 ): string { return wp_nonce_url( add_query_arg( array( 'action' => 'mapsemble_reindex', 'map' => (string) $map_id, 'offset' => (string) $offset, ), admin_url( 'admin-post.php' ) ), 'mapsemble_reindex_' . $map_id ); } /** * The available location sources. * * @return array Source key => label. */ private function get_location_sources(): array { $sources = array( MapConfig::SOURCE_METABOX => __( 'Built-in location box (latitude/longitude fields on the edit screen)', 'mapsemble' ), ); /** * Filters the available location sources for maps. * * @param array $sources Source key => label. */ return (array) apply_filters( 'mapsemble_location_sources', $sources ); } /** * Appends a one-shot notice for the redirect that follows a save. * * A stack rather than a slot, because one save can have more than one * thing to say — mirror_to_mapsemble() reports the plan and the host as * independent facts — and a slot means whichever is said last wins. * Keyed by user *and* map: keyed by user alone, any other admin page the * same user had open consumed the notice, and two maps raced each other. * * @param int $post_id The map post the notice belongs to. * @param string $type 'success', 'warning' or 'error'. * @param string $message The message. */ private function set_notice( int $post_id, string $type, string $message ): void { $key = self::NOTICE_TRANSIENT . get_current_user_id() . '_' . $post_id; $notices = get_transient( $key ); if ( ! is_array( $notices ) ) { $notices = array(); } $notices[] = array( 'type' => $type, 'message' => $message, ); set_transient( $key, $notices, MINUTE_IN_SECONDS ); } /** * Renders and clears the one-shot notices, one div per notice. * * Only on the owning map's edit screen: both producers redirect back to * it, so the notice is always reachable, and nothing else can eat it. */ public function render_notice(): void { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( null === $screen || 'post' !== $screen->base || MapPostType::POST_TYPE !== $screen->post_type ) { return; } $post_id = (int) get_the_ID(); if ( $post_id <= 0 ) { return; } $key = self::NOTICE_TRANSIENT . get_current_user_id() . '_' . $post_id; $notices = get_transient( $key ); if ( ! is_array( $notices ) ) { return; } delete_transient( $key ); foreach ( $notices as $notice ) { // The per-entry guard makes a transient in the pre-stack shape — // a bare {type, message} written up to a minute before a deploy — // render nothing rather than warn on the string entries. if ( ! is_array( $notice ) || empty( $notice['message'] ) ) { continue; } printf( '

%2$s

', esc_attr( (string) ( $notice['type'] ?? 'info' ) ), esc_html( (string) $notice['message'] ) ); } } /** * The config for a post, or an empty one for a map being created. * * @param WP_Post $post The map post. */ private function config_for( WP_Post $post ): MapConfig { return $this->repository->get( (int) $post->ID ) ?? new MapConfig( (int) $post->ID, (string) $post->post_title, '' ); } /** * Whether the map has never been saved: a new map is an auto-draft * with an id but no saved configuration. * * @param WP_Post $post The map being edited. */ private static function is_unsaved( WP_Post $post ): bool { return 'auto-draft' === $post->post_status; } }