plugin = $plugin; } /** * Registers the handlers. */ public function register(): void { add_action( 'admin_post_' . self::SAVE_CATEGORIES, array( $this, 'save_categories' ) ); add_action( 'admin_post_' . self::SAVE_ATTRIBUTES, array( $this, 'save_attributes' ) ); add_action( 'admin_post_' . self::REFRESH_CATEGORIES, array( $this, 'refresh_categories' ) ); add_action( 'wp_ajax_' . self::AJAX_SEARCH, array( $this, 'ajax_search' ) ); add_action( 'wp_ajax_' . self::AJAX_SUGGEST, array( $this, 'ajax_suggest' ) ); } /** * Saves the category map. */ public function save_categories(): void { $this->guard( self::SAVE_CATEGORIES ); $channel_label = $this->plugin->requested_channel()->label(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce checked in guard(); values cast to integers below. $posted = isset( $_POST['tidelist_map'] ) && is_array( $_POST['tidelist_map'] ) ? wp_unslash( $_POST['tidelist_map'] ) : array(); $channel = $this->plugin->requested_channel(); $map = $this->plugin->category_map( $channel->id() ); $tree = $channel->taxonomy()->index(); $skipped = 0; foreach ( $posted as $wc_id => $remote_id ) { $wc_id = absint( $wc_id ); $remote_id = absint( $remote_id ); if ( $remote_id > 0 && $tree->count() > 0 && ! $tree->is_leaf( $remote_id ) ) { ++$skipped; // Marketplaces take adverts in the last level of their tree only. continue; } $map->set( $wc_id, $remote_id ); } $settings = $this->plugin->settings(); $settings->update( array( 'category_map' => $map->to_setting( (array) $settings->get( 'category_map' ), $channel->id() ) ) ); Flash::add( 'success', __( 'Category mapping saved.', 'tidelist' ) ); if ( $skipped ) { /* translators: %d: number of categories */ Flash::add( 'warning', sprintf( Text::channel( __( '%d choices were not saved because they are not in the last level of the {channel} categories.', 'tidelist' ), $channel_label ), $skipped ) ); } $this->back(); } /** * Saves the attribute rules of one marketplace category. */ public function save_attributes(): void { $this->guard( self::SAVE_ATTRIBUTES ); $channel = $this->plugin->requested_channel(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce checked in guard(); every value is sanitized below. $cat = isset( $_POST['remote_cat'] ) ? absint( $_POST['remote_cat'] ) : 0; $posted = isset( $_POST['tidelist_attr'] ) && is_array( $_POST['tidelist_attr'] ) ? wp_unslash( $_POST['tidelist_attr'] ) : array(); // phpcs:enable if ( ! $cat ) { $this->back_to( Menu::attributes_url( 0, $channel->id() ) ); } $map = $this->plugin->attribute_map( $channel->id() ); $known = array_column( $channel->taxonomy()->attributes( $cat ), 'code' ); $map->clear_category( $cat ); foreach ( $posted as $code => $data ) { $code = sanitize_text_field( (string) $code ); if ( ! is_array( $data ) || ( $known && ! in_array( $code, $known, true ) ) ) { continue; } $source = AttributeMappingPage::parse_source( sanitize_text_field( (string) ( $data['source'] ?? 'skip' ) ) ); $values = array(); $terms = isset( $data['terms'] ) && is_array( $data['terms'] ) ? $data['terms'] : array(); $mapped = isset( $data['mapped'] ) && is_array( $data['mapped'] ) ? $data['mapped'] : array(); foreach ( $terms as $i => $term ) { $chosen = sanitize_text_field( (string) ( $mapped[ $i ] ?? '' ) ); if ( '' !== $chosen ) { $values[ sanitize_text_field( (string) $term ) ] = $chosen; } } $map->set_rule( $cat, $code, new AttributeRule( array( 'type' => $source['type'], 'key' => $source['key'], 'fixed' => sanitize_text_field( (string) ( $data['fixed'] ?? '' ) ), 'values' => $values, ) ) ); } $settings = $this->plugin->settings(); $settings->update( array( 'attribute_map' => $map->to_setting( (array) $settings->get( 'attribute_map' ), $channel->id() ) ) ); Flash::add( 'success', __( 'Attribute mapping saved.', 'tidelist' ) ); $this->back_to( Menu::attributes_url( $cat, $channel->id() ) ); } /** * Starts a background refresh of the marketplace category list. */ public function refresh_categories(): void { $this->guard( self::REFRESH_CATEGORIES ); $channel = $this->plugin->requested_channel(); $channel_label = $channel->label(); ( new LoadCategories( $this->plugin ) )->start( $channel->id() ); Flash::add( 'success', Text::channel( __( 'Loading the {channel} categories in the background. This takes a few minutes; reload this page to see the progress.', 'tidelist' ), $channel_label ) ); $this->back(); } /** * Live search in the marketplace categories (leaf categories only). */ public function ajax_search(): void { $this->ajax_guard( self::AJAX_SEARCH ); $query = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked in ajax_guard(). wp_send_json_success( array( 'items' => $this->plugin->requested_channel()->taxonomy()->index()->search( $query, 15 ) ) ); } /** * The marketplace's own category suggestions for a title (a read-only call). */ public function ajax_suggest(): void { $this->ajax_guard( self::AJAX_SUGGEST ); $title = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked in ajax_guard(). try { $items = $this->plugin->requested_channel()->taxonomy()->suggest( $title ); } catch ( ChannelException $e ) { wp_send_json_error( array( 'message' => $e->getMessage() ) ); } wp_send_json_success( array( 'items' => $items ) ); } /** * Capability and nonce check for a form handler. * * @param string $action Action (also the nonce action). */ private function guard( string $action ): void { if ( ! current_user_can( Plugin::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'tidelist' ), '', array( 'response' => 403 ) ); } check_admin_referer( $action ); } /** * Capability and nonce check for an ajax handler. * * @param string $action Nonce action. */ private function ajax_guard( string $action ): void { if ( ! current_user_can( Plugin::CAPABILITY ) ) { wp_send_json_error( array( 'message' => 'forbidden' ), 403 ); } check_ajax_referer( $action, '_ajax_nonce' ); } /** * Back to the mapping screen. */ private function back(): void { $this->back_to( Menu::categories_url() ); } /** * Redirects to a screen. * * @param string $url Address. */ private function back_to( string $url ): void { wp_safe_redirect( $url ); exit; } }