|null */ private ?array $all_memo = null; /** * Per-request memo of remote-id lookups. `false` means "asked, none". * * @var array */ private array $by_map_id_memo = array(); /** * Returns all map configs, keyed by post id. * * Memoised for the request: `Coordinates::find_config_for_post()` and the * location meta box both call this on every post save, where it used to be * a single option read and is now a query. * * @return array */ public function all(): array { if ( null !== $this->all_memo ) { return $this->all_memo; } $query = new WP_Query( array( 'post_type' => MapPostType::POST_TYPE, // Maps are forced to `publish` on save — they have no front // end, so a draft would only be a way to be surprised. What // this really excludes is the trash: trashing a map is how you // take it out of service without deleting it. 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_term_cache' => false, ) ); $ids = array_map( 'intval', $query->posts ); update_meta_cache( 'post', $ids ); $configs = array(); foreach ( $ids as $id ) { $config = $this->hydrate( $id ); if ( null !== $config ) { $configs[ $id ] = $config; } } $this->all_memo = $configs; return $configs; } /** * Returns one config by its post id. * * Hydrated directly rather than through `all()`, so a map created or * changed earlier in the same request reads back correctly. * * @param int $id Post id. */ public function get( int $id ): ?MapConfig { return $id > 0 ? $this->hydrate( $id ) : null; } /** * Returns the config bound to a remote map id. * * A meta lookup rather than a scan of `all()`: this runs on every request * to the public features feed. * * @param string $map_id Remote Mapsemble map id. */ public function find_by_map_id( string $map_id ): ?MapConfig { if ( '' === $map_id ) { return null; } if ( isset( $this->by_map_id_memo[ $map_id ] ) ) { $memo = $this->by_map_id_memo[ $map_id ]; return false === $memo ? null : $memo; } $ids = get_posts( array( 'post_type' => MapPostType::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'meta_key' => MapPostType::META_MAP_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- an indexed single-row lookup, not a scan. 'meta_value' => $map_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- see above. ) ); $config = array() !== $ids ? $this->hydrate( (int) $ids[0] ) : null; $this->by_map_id_memo[ $map_id ] = $config ?? false; return $config; } /** * Inserts or updates a config, assigning a post id when missing. * * The admin screen does not use this — it saves through the post editor * and writes its own meta, so that it can order that write against the * Mapsemble PATCH. This is the programmatic route, used by tests and by * anything building maps in code. * * @param MapConfig $config The config to persist. * @return MapConfig The persisted config, with its id. */ public function save( MapConfig $config ): MapConfig { $postarr = array( 'post_type' => MapPostType::POST_TYPE, 'post_status' => 'publish', 'post_title' => $config->label, ); if ( $config->id > 0 ) { $postarr['ID'] = $config->id; } $id = wp_insert_post( $postarr, true ); if ( is_wp_error( $id ) ) { return $config; } $config->id = (int) $id; $this->write_meta( $config ); return $config; } /** * Writes a config's meta onto its post. * * @param MapConfig $config The config to write. */ public function write_meta( MapConfig $config ): void { $previous_map_id = (string) get_post_meta( $config->id, MapPostType::META_MAP_ID, true ); update_post_meta( $config->id, MapPostType::META_MAP_ID, $config->map_id ); update_post_meta( $config->id, MapPostType::META_MODE, $config->mode ); update_post_meta( $config->id, MapPostType::META_POST_TYPE, $config->post_type ); update_post_meta( $config->id, MapPostType::META_LOCATION_SOURCE, $config->location_source ); update_post_meta( $config->id, MapPostType::META_ACF_FIELD, $config->acf_field ); $this->write_json_meta( $config->id, MapPostType::META_STATIC_CONDITIONS, $config->static_conditions ); $this->write_json_meta( $config->id, MapPostType::META_FILTER_MAPPINGS, $config->filter_mappings ); $this->write_json_meta( $config->id, MapPostType::META_SORT_MAPPINGS, $config->sort_mappings ); $this->write_json_meta( $config->id, MapPostType::META_PROPERTIES, $config->properties ); update_post_meta( $config->id, MapPostType::META_LAT_META_KEY, $config->lat_meta_key ); update_post_meta( $config->id, MapPostType::META_LNG_META_KEY, $config->lng_meta_key ); $this->flush(); /** * Fires after a map configuration's meta has been written. * * The editor's save reaches `save_post` too, but a programmatic * save() writes its meta after that hook has already fired — this is * the one signal that covers both. * * @param MapConfig $config The persisted config. * @param string $previous_map_id The map id stored before this write ('' when none). */ do_action( 'mapsemble_map_config_saved', $config, $previous_map_id ); } /** * Deletes a config outright. * * @param int $id Post id. */ public function delete( int $id ): void { wp_delete_post( $id, true ); $this->flush(); } /** * Returns every post type served by at least one WordPress-mode map. * * Hosted maps are excluded: they serve nothing from this site, and their * retained post type is only there so switching back is lossless. * * @return string[] */ public function used_post_types(): array { $types = array(); foreach ( $this->all() as $config ) { if ( ! $config->is_hosted() ) { $types[ $config->post_type ] = true; } } return array_keys( $types ); } /** * Writes one JSON meta, deleting it when the value is empty. * * An absent key and an empty setting mean the same thing, and deleting * keeps the postmeta table free of `''` rows the sanitizer would produce. * * @param int $post_id Post id. * @param string $key Meta key. * @param array $value The decoded value. */ private function write_json_meta( int $post_id, string $key, array $value ): void { if ( array() === $value ) { delete_post_meta( $post_id, $key ); return; } update_post_meta( $post_id, $key, (string) wp_json_encode( $value ) ); } /** * Reads one JSON meta back into an array. * * @param int $post_id Post id. * @param string $key Meta key. * @return array The decoded value; empty when absent or undecodable. */ private function read_json_meta( int $post_id, string $key ): array { $raw = get_post_meta( $post_id, $key, true ); if ( ! is_string( $raw ) || '' === $raw ) { return array(); } $decoded = json_decode( $raw, true ); return is_array( $decoded ) ? $decoded : array(); } /** * Discards the per-request memos. */ public function flush(): void { $this->all_memo = null; $this->by_map_id_memo = array(); } /** * Builds a config from a post id, or null when there is no such map. * * @param int $id Post id. */ private function hydrate( int $id ): ?MapConfig { $post = get_post( $id ); if ( ! $post instanceof \WP_Post || MapPostType::POST_TYPE !== $post->post_type ) { return null; } $post_type = (string) get_post_meta( $id, MapPostType::META_POST_TYPE, true ); $source = (string) get_post_meta( $id, MapPostType::META_LOCATION_SOURCE, true ); return new MapConfig( $id, (string) $post->post_title, (string) get_post_meta( $id, MapPostType::META_MAP_ID, true ), MapPostType::sanitize_mode( get_post_meta( $id, MapPostType::META_MODE, true ) ), '' !== $post_type ? $post_type : 'post', '' !== $source ? $source : MapConfig::SOURCE_METABOX, (string) get_post_meta( $id, MapPostType::META_ACF_FIELD, true ), $this->read_json_meta( $id, MapPostType::META_STATIC_CONDITIONS ), $this->read_json_meta( $id, MapPostType::META_FILTER_MAPPINGS ), $this->read_json_meta( $id, MapPostType::META_SORT_MAPPINGS ), $this->read_json_meta( $id, MapPostType::META_PROPERTIES ), (string) get_post_meta( $id, MapPostType::META_LAT_META_KEY, true ), (string) get_post_meta( $id, MapPostType::META_LNG_META_KEY, true ) ); } }