repository = $repository; $this->coordinates = $coordinates; $this->api = $api ?? new Client(); } /** * Hooks the meta box and save handler. */ public function register(): void { add_action( 'add_meta_boxes', array( $this, 'add_boxes' ) ); add_action( 'save_post', array( $this, 'save' ), 10, 2 ); add_action( 'admin_notices', array( $this, 'notice' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); } /** * What went wrong, by CoordinateInput error code. The script receives * the same strings, so the browser and the server say the same thing — * on blur before any save as much as after one, so none claims a save. * * @return array */ private function messages(): array { return array( 'half' => __( 'Enter both latitude and longitude — until this is fixed, the stored location stays as it was.', 'mapsemble' ), 'not_number' => __( 'Latitude and longitude must be numbers, like 51.9244 — until this is fixed, the stored location stays as it was.', 'mapsemble' ), 'lat_range' => __( 'Latitude must be between -90 and 90 — until this is fixed, the stored location stays as it was.', 'mapsemble' ), 'lng_range' => __( 'Longitude must be between -180 and 180 — until this is fixed, the stored location stays as it was.', 'mapsemble' ), ); } /** * The address search's words: its own, plus the endpoint's error * messages keyed by REST error code (GeocodeController is their one * source). `found` holds a %s, or %1$s, for the map link's text. * * @return array */ private function search_messages(): array { return array_merge( array( 'empty' => __( 'Type an address first.', 'mapsemble' ), 'searching' => __( 'Searching…', 'mapsemble' ), /* translators: %s: link text, "check it on OpenStreetMap". Write it as %s or %1$s. */ 'found' => __( 'Found — %s', 'mapsemble' ), 'check' => __( 'check it on OpenStreetMap', 'mapsemble' ), 'kept' => __( 'Found, but the coordinates were edited during the search, so they were left as typed.', 'mapsemble' ), 'retrySoon' => (string) GeocodeController::RETRY_SOON, ), GeocodeController::messages() ); } /** * Whether the current admin screen edits a post the box is on. */ private function on_box_screen(): bool { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; return null !== $screen && 'post' === $screen->base && in_array( $screen->post_type, $this->metabox_post_types(), true ); } /** * The error transient key for the current user and a post. * * @param int $post_id Post ID. */ private function error_key( int $post_id ): string { return self::ERROR_TRANSIENT . get_current_user_id() . '_' . $post_id; } /** * Enqueues the box's script on the post edit screens it appears on. * * @param string $hook_suffix Current admin page. */ public function enqueue( string $hook_suffix ): void { if ( ! in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true ) || ! $this->on_box_screen() ) { return; } $screen = get_current_screen(); // Only the block editor has a notices store to report a save to. // apiFetch, for the address search, works in either editor and // brings the REST nonce with it. $deps = null !== $screen && $screen->is_block_editor() ? array( 'wp-api-fetch', 'wp-data', 'wp-notices', 'wp-editor' ) : array( 'wp-api-fetch' ); wp_enqueue_script( self::SCRIPT_HANDLE, plugins_url( 'assets/js/location-box.js', MAPSEMBLE_PLUGIN_FILE ), $deps, MAPSEMBLE_VERSION, true ); wp_localize_script( self::SCRIPT_HANDLE, 'mapsembleLocationBox', array( 'messages' => $this->messages(), 'search' => $this->search_messages(), ) ); } /** * Classic editor: says why the last save left the location as it was. * * The block editor saves meta boxes in a request of their own and never * reloads, so its script reports the error at save time instead; the * message is still consumed here, or it would linger for the next visit. */ public function notice(): void { if ( ! $this->on_box_screen() ) { return; } $post = get_post(); if ( ! $post instanceof \WP_Post ) { return; } $key = $this->error_key( (int) $post->ID ); $message = get_transient( $key ); if ( false === $message ) { return; } delete_transient( $key ); $screen = get_current_screen(); if ( ! is_string( $message ) || ( null !== $screen && $screen->is_block_editor() ) ) { return; } printf( '

%s

', esc_html( $message ) ); } /** * Post types whose configs use the built-in meta box. * * Hosted maps are excluded: their locations live in Mapsemble and are * edited there, so a latitude field on this site's posts would collect * values nothing ever reads. * * @return string[] */ private function metabox_post_types(): array { $types = array(); foreach ( $this->repository->all() as $config ) { if ( ! $config->is_hosted() && MapConfig::SOURCE_METABOX === $config->location_source ) { $types[ $config->post_type ] = true; } } return array_keys( $types ); } /** * Registers the meta box on the relevant post types. */ public function add_boxes(): void { foreach ( $this->metabox_post_types() as $post_type ) { add_meta_box( 'mapsemble_location', __( 'Map location', 'mapsemble' ), array( $this, 'render' ), $post_type, 'side' ); } } /** * Renders the meta box fields. * * Plain text fields: type=number drops a comma decimal in an en-US * browser, and inputmode="decimal" gets iOS's keypad, which has no minus * key. The message line is a polite live region that stays in the * accessibility tree, empty while there is nothing to say. * * The address search above them has no name, so it never posts: it only * fills the two fields, which save as if typed. * * @param \WP_Post $post The post being edited. */ public function render( \WP_Post $post ): void { $coords = $this->coordinates->get( (int) $post->ID ); $hidden = '1' === get_post_meta( (int) $post->ID, Coordinates::META_HIDDEN, true ); wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); $this->render_search(); ?>



api->has_credentials() ) { $hint = __( 'Connect Mapsemble to search by address.', 'mapsemble' ); if ( current_user_can( Capabilities::MANAGE ) ) { printf( '

%s

', esc_url( SettingsPage::url() ), esc_html( $hint ) ); } else { printf( '

%s

', esc_html( $hint ) ); } return; } ?>


post_type, $this->metabox_post_types(), true ) ) { return; } $lat = $this->posted( 'mapsemble_lat' ); $lng = $this->posted( 'mapsemble_lng' ); $parsed = null !== $lat && null !== $lng ? CoordinateInput::parse( $lat, $lng ) : array( // A forged array is no number either. 'status' => 'invalid', 'error' => 'not_number', ); if ( 'invalid' === $parsed['status'] ) { // A typo must never cost the location that was already there. set_transient( $this->error_key( $post_id ), $this->messages()[ $parsed['error'] ], MINUTE_IN_SECONDS ); } else { delete_transient( $this->error_key( $post_id ) ); $this->coordinates->write( $post_id, 'valid' === $parsed['status'] ? array( 'lat' => $parsed['lat'], 'lng' => $parsed['lng'], ) : null ); } $this->coordinates->set_hidden( $post_id, ! empty( $_POST['mapsemble_hidden'] ) ); } /** * A submitted coordinate field, unslashed but otherwise as typed. * * Deliberately not sanitize_text_field(): it rewrites input ("5%201" * becomes "51", "<5>" becomes ""), which would save numbers nobody * typed and disagree with the script. CoordinateInput is the validator * and only ever yields floats, so nothing raw reaches the database. * * @param string $key Field name. * @return string|null Null when the field is not a string (an array, say). */ private function posted( string $key ): ?string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- save() verified the nonce. if ( ! isset( $_POST[ $key ] ) ) { return ''; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified in save(); CoordinateInput::parse() validates and yields only floats. $raw = wp_unslash( $_POST[ $key ] ); return is_string( $raw ) ? $raw : null; } }