plugin = $plugin; } /** * Registers hooks. */ public function register() { add_action( 'admin_menu', array( $this, 'menu' ), 20 ); add_action( 'admin_post_' . self::ACTION, array( $this, 'save' ) ); add_action( 'admin_post_' . self::EXPORT_ACTION, array( $this, 'export' ) ); add_action( 'admin_post_' . self::CSV_ACTION, array( $this, 'handle_csv' ) ); } /** * Transient holding the current user's import preview. * * @return string */ private static function plan_key() { return 'orbitstem_seo_csv_plan_' . get_current_user_id(); } /** * Transient holding the current user's last import, for "Undo this import". * * @return string */ private static function last_key() { return 'orbitstem_seo_csv_last_' . get_current_user_id(); } /** * Streams a CSV file of the filtered items (all pages). */ public function export() { if ( ! current_user_can( Admin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'orbitstem-seo' ), 403 ); } check_admin_referer( self::EXPORT_ACTION ); $filters = $this->filters(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce checked above. $scope = isset( $_GET['scope'] ) && 'all' === $_GET['scope'] ? 'all' : $filters['post_type']; $ids = $this->plugin->csv->export_ids( $scope, $filters['missing'], $filters['s'] ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="orbitstem-seo-' . sanitize_file_name( $scope ) . '-' . gmdate( 'Y-m-d' ) . '.csv"' ); header( 'X-Content-Type-Options: nosniff' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- the response body. $out = fopen( 'php://output', 'w' ); $this->plugin->csv->write( $out, $ids ); fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- the response body. exit; } /** * Import steps: preview (upload), apply, cancel, undo. */ public function handle_csv() { if ( ! current_user_can( Admin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'orbitstem-seo' ), 403 ); } // A body larger than post_max_size arrives empty, nonce included: say why instead of "link expired". if ( empty( $_POST ) && ! empty( $_SERVER['CONTENT_LENGTH'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nothing is changed; only a message is shown. RedirectsPage::notice( 'error', self::too_big_message() ); wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG ) ); exit; } check_admin_referer( self::CSV_ACTION ); $task = isset( $_POST['task'] ) ? sanitize_key( wp_unslash( $_POST['task'] ) ) : ''; $args = array( 'page' => self::SLUG ); if ( 'preview' === $task ) { $text = $this->uploaded_text(); $plan = is_wp_error( $text ) ? $text : $this->plugin->csv->parse( $text ); if ( is_wp_error( $plan ) ) { RedirectsPage::notice( 'error', $plan->get_error_message() ); } else { $plan = $this->plugin->csv->plan( $plan ); $name = isset( $_FILES['csv_file']['name'] ) ? sanitize_file_name( wp_unslash( $_FILES['csv_file']['name'] ) ) : ''; set_transient( self::plan_key(), $plan + array( 'file' => $name ), HOUR_IN_SECONDS ); $args['csv'] = 'preview'; } } elseif ( 'apply' === $task ) { $plan = get_transient( self::plan_key() ); delete_transient( self::plan_key() ); if ( ! is_array( $plan ) ) { RedirectsPage::notice( 'error', __( 'The preview expired. Upload the file again.', 'orbitstem-seo' ) ); } else { $result = $this->plugin->csv->import( $plan['changes'] ); set_transient( self::last_key(), $result + array( 'time' => time() ), DAY_IN_SECONDS ); $args['csv'] = 'done'; } } elseif ( 'cancel' === $task ) { delete_transient( self::plan_key() ); } elseif ( 'undo' === $task ) { $last = get_transient( self::last_key() ); if ( is_array( $last ) && ! empty( $last['change_ids'] ) ) { $undone = 0; $refused = 0; foreach ( array_reverse( $last['change_ids'] ) as $change_id ) { if ( true === $this->plugin->repair->rollback( $change_id ) ) { ++$undone; } else { ++$refused; } } delete_transient( self::last_key() ); RedirectsPage::notice( $refused ? 'error' : 'success', /* translators: 1: posts restored, 2: posts left as they are */ sprintf( __( 'Import undone. Restored: %1$d. Left as they are because they were changed again after the import: %2$d (see Repair > History).', 'orbitstem-seo' ), $undone, $refused ) ); } } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } /** * Message for a file over the server's upload limit. * * @return string */ private static function too_big_message() { /* translators: %s: maximum upload size, e.g. "8 MB" */ return sprintf( __( 'The file is larger than this server accepts (%s). Split it into smaller files.', 'orbitstem-seo' ), size_format( wp_max_upload_size() ) ); } /** * Contents of the uploaded CSV file. * * @return string|\WP_Error */ private function uploaded_text() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- called from handle_csv() after check_admin_referer(). // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a temporary file path from PHP, checked with is_uploaded_file(). $file = isset( $_FILES['csv_file'] ) && is_array( $_FILES['csv_file'] ) ? $_FILES['csv_file'] : null; $tmp = $file && isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : ''; $name = $file && isset( $file['name'] ) ? sanitize_file_name( wp_unslash( (string) $file['name'] ) ) : ''; // phpcs:enable if ( $file && isset( $file['error'] ) && in_array( (int) $file['error'], array( UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE ), true ) ) { return new \WP_Error( 'size', self::too_big_message() ); } if ( ! $file || ! empty( $file['error'] ) || '' === $tmp || ! is_uploaded_file( $tmp ) ) { return new \WP_Error( 'upload', __( 'Choose a CSV file to upload.', 'orbitstem-seo' ) ); } if ( ! preg_match( '/\.(csv|txt)$/i', $name ) ) { return new \WP_Error( 'type', __( 'Upload a .csv file.', 'orbitstem-seo' ) ); } if ( filesize( $tmp ) > \Orbitstem\SEO\Content\SeoCsv::MAX_BYTES ) { return new \WP_Error( 'size', __( 'The file is larger than 4 MB. Split it into smaller files.', 'orbitstem-seo' ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local uploaded file. $text = file_get_contents( $tmp ); return false === $text ? new \WP_Error( 'read', __( 'The file could not be read.', 'orbitstem-seo' ) ) : $text; } /** * Adds the submenu page. */ public function menu() { add_submenu_page( Admin::SLUG, __( 'Bulk Editor', 'orbitstem-seo' ), __( 'Bulk Editor', 'orbitstem-seo' ), Admin::capability(), self::SLUG, array( $this, 'render' ) ); } /** * Current filters from the query string. * * @return array{post_type:string,missing:string,s:string,paged:int} */ private function filters() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters. $types = array_keys( $this->plugin->types->post_types() ); // Not "post_type": admin.php treats that argument specially and fails with "Cannot load". $post_type = isset( $_GET['os_type'] ) ? sanitize_key( wp_unslash( $_GET['os_type'] ) ) : 'post'; $missing = isset( $_GET['missing'] ) ? sanitize_key( wp_unslash( $_GET['missing'] ) ) : ''; $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $paged = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:enable return array( 'post_type' => in_array( $post_type, $types, true ) ? $post_type : ( $types ? $types[0] : 'post' ), 'missing' => in_array( $missing, array( 'title', 'description' ), true ) ? $missing : '', 's' => $search, 'paged' => $paged, ); } /** * Query for the current page of items. * * @param array $filters Filters. * @return WP_Query */ public function query( array $filters ) { $args = array( 'post_type' => $filters['post_type'], 'post_status' => 'publish', 'posts_per_page' => self::PER_PAGE, 'paged' => $filters['paged'], 'orderby' => 'date', 'order' => 'DESC', 'update_post_term_cache' => false, ); if ( '' !== $filters['s'] ) { $args['s'] = $filters['s']; } if ( '' !== $filters['missing'] ) { $args['meta_query'] = array( MetaRepository::missing_clause( $filters['missing'] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query } return new WP_Query( $args ); } /** * Renders the page. */ public function render() { if ( ! current_user_can( Admin::capability() ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'orbitstem-seo' ) ); } $filters = $this->filters(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display of counts after redirect. $saved = isset( $_GET['orbitstem_seo_saved'] ) ? absint( $_GET['orbitstem_seo_saved'] ) : null; $conflicts = isset( $_GET['orbitstem_seo_conflicts'] ) ? absint( $_GET['orbitstem_seo_conflicts'] ) : 0; $csv_view = isset( $_GET['csv'] ) ? sanitize_key( wp_unslash( $_GET['csv'] ) ) : ''; // phpcs:enable $plan = 'preview' === $csv_view ? get_transient( self::plan_key() ) : false; if ( is_array( $plan ) ) { $this->render_csv_preview( $plan ); return; } $query = $this->query( $filters ); $meta = $this->plugin->meta; Ui::open( self::SLUG, __( 'Bulk Editor', 'orbitstem-seo' ), __( 'Edit SEO titles, descriptions and noindex for many items at once, or by spreadsheet.', 'orbitstem-seo' ) ); ?> render_csv_result(); } ?>