*/ private const HEADERS = [ 'source' => 'source', 'target' => 'target_url', 'target_url' => 'target_url', 'destination' => 'target_url', 'match_type' => 'match_type', 'target_type' => 'target_type', 'target_page_id' => 'target_page_id', 'page_id' => 'target_page_id', 'redirect_type' => 'redirect_type', 'status_code' => 'redirect_type', 'is_active' => 'is_active', 'active' => 'is_active', 'enabled' => 'is_active', 'query_handling' => 'query_handling', 'notes' => 'notes', 'note' => 'notes', ]; /** * Path to the CSV file backing this instance. * * `null` until {@see self::bind()} is called. The Registry exposes * a singleton instance for source-listing purposes; the REST layer * clones it and calls `bind()` for per-request reads. * * @since 4.0.3 * * @var string|null */ private $path = null; /** * Accumulated per-row skip messages. * * Populated as {@see self::read()} iterates the file. The REST * layer reads this at the end via {@see self::skipSummary()} and * folds it into the per-row error list shown in the UI. * * @since 4.0.3 * * @var array */ private $skips = []; /** * Bind this source to a specific uploaded file. * * Returns `$this` for fluent chaining at the REST layer. * * @since 4.0.3 * * @param string $path Absolute path to the stashed CSV. * * @return self */ public function bind( string $path ): self { $this->path = $path; $this->skips = []; return $this; } /** * Source id used on the wire and as the React state key. * * @since 4.0.3 * * @return string */ public function id(): string { return 'csv'; } /** * Human-readable name shown in the picker. * * @since 4.0.3 * * @return string */ public function label(): string { return __( 'CSV upload', '404-to-301' ); } /** * CSV is always available — the user uploads the file inline. * * @since 4.0.3 * * @return bool */ public function isAvailable(): bool { return true; } /** * Count data rows in the bound file. * * Cheap line-count via `fgets()` — we don't parse the CSV here, * just count newlines for the UI's progress meter. * * @since 4.0.3 * * @return int */ public function count(): int { if ( null === $this->path || ! is_readable( $this->path ) ) { return 0; } // `WP_Filesystem` is the wrong tool here — counting rows in a // multi-megabyte CSV through a Filesystem abstraction would // load the file into memory first. We need to stream. // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose $handle = fopen( $this->path, 'r' ); if ( false === $handle ) { return 0; } $lines = 0; while ( ! feof( $handle ) ) { $buffer = fgets( $handle, 1024 * 64 ); if ( false === $buffer ) { break; } $lines += substr_count( $buffer, "\n" ); } fclose( $handle ); // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose // Subtract 1 for the header row, clamp to 0+ for files that // don't end on a newline (single-row files would otherwise // return -1). return max( 0, $lines - 1 ); } /** * Yield one batch of mapped rows from the stashed CSV. * * @since 4.0.3 * * @param int $offset Row offset within the data rows. * @param int $limit Maximum rows in this batch. * * @return iterable> */ public function read( int $offset, int $limit ): iterable { if ( null === $this->path || ! is_readable( $this->path ) ) { return; } // Streaming read — see `count()` for why `WP_Filesystem` would // be the wrong tool here. // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose $handle = fopen( $this->path, 'r' ); if ( false === $handle ) { return; } // Strip an optional UTF-8 BOM so the first header doesn't end // up as `source` — Excel adds one on Save-As-CSV. $bom = fread( $handle, 3 ); if ( "\xEF\xBB\xBF" !== $bom ) { rewind( $handle ); } $headers = $this->readHeaders( $handle ); if ( empty( $headers ) || ! in_array( 'source', $headers, true ) ) { fclose( $handle ); return; } $dataRow = 0; $emitted = 0; // Header row was already consumed; data rows are 1-indexed // inside this loop and presented to the user as "row N+1" to // match what they'd see in Excel (where the header is row 1). $csvRow = 1; // `$values = fgetcsv(...)` is the idiomatic CSV read loop; the // assignment-in-condition warning is a false positive here. // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found, Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition while ( ( $values = fgetcsv( $handle, 0, ',', '"', '' ) ) !== false ) { ++$csvRow; // Skip blank lines — fgetcsv returns `[ null ]` for them. if ( 1 === count( $values ) && ( null === $values[0] || '' === $values[0] ) ) { continue; } if ( $dataRow >= self::MAX_ROWS ) { $this->skips[] = [ 'row' => $csvRow, /* translators: %d: maximum row count. */ 'message' => sprintf( __( 'Stopped after %d rows. Split the file and re-import.', '404-to-301' ), self::MAX_ROWS ), ]; break; } if ( $dataRow < $offset ) { ++$dataRow; continue; } ++$dataRow; $row = $this->shapeRow( $headers, $values ); $row['_csv_row'] = $csvRow; yield $row; ++$emitted; if ( $emitted >= $limit ) { break; } } fclose( $handle ); // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.WP.AlternativeFunctions.file_system_operations_fclose } /** * Per-row skip reasons accumulated by the most recent read() pass. * * @since 4.0.3 * * @return array */ public function skipSummary(): array { return $this->skips; } /** * Read the header row and translate each column to its canonical key. * * Unknown columns map to an empty string so {@see self::shapeRow()} * can skip them by index without a second lookup. * * @since 4.0.3 * * @param resource $handle Open file handle positioned at row 0. * * @return array */ private function readHeaders( $handle ): array { $raw = fgetcsv( $handle, 0, ',', '"', '' ); if ( ! is_array( $raw ) ) { return []; } $headers = []; foreach ( $raw as $col ) { $key = strtolower( trim( (string) $col ) ); $headers[] = self::HEADERS[ $key ] ?? ''; } return $headers; } /** * Turn one CSV row into a column => value map the model can accept. * * @since 4.0.3 * * @param array $headers Canonical column key per CSV index. * @param array $values Raw cell values for this row. * * @return array */ private function shapeRow( array $headers, array $values ): array { $row = []; foreach ( $headers as $i => $key ) { if ( '' === $key ) { continue; } $value = isset( $values[ $i ] ) ? trim( (string) $values[ $i ] ) : ''; if ( '' === $value ) { continue; } switch ( $key ) { case 'source': case 'target_url': case 'notes': $row[ $key ] = sanitize_text_field( $value ); break; case 'match_type': $lower = strtolower( $value ); $row[ $key ] = in_array( $lower, [ 'exact', 'prefix', 'regex' ], true ) ? $lower : 'exact'; break; case 'target_type': $lower = strtolower( $value ); $row[ $key ] = in_array( $lower, [ 'link', 'page', 'none' ], true ) ? $lower : 'link'; break; case 'query_handling': $lower = strtolower( $value ); $row[ $key ] = in_array( $lower, [ 'ignore', 'preserve', 'require' ], true ) ? $lower : 'ignore'; break; case 'target_page_id': $row[ $key ] = (int) $value; break; case 'redirect_type': $code = (int) $value; $row[ $key ] = in_array( $code, [ 301, 302, 303, 307, 308, 410, 451 ], true ) ? $code : 301; break; case 'is_active': $row[ $key ] = $this->parseBool( $value ) ? 1 : 0; break; default: // Unknown columns are dropped rather than trusted. break; } } // Apply the same create-time defaults the REST endpoint uses so // imports and single-row creates produce identical rows. $row['match_type'] = $row['match_type'] ?? 'exact'; $row['target_type'] = $row['target_type'] ?? 'link'; $row['redirect_type'] = $row['redirect_type'] ?? 301; $row['is_active'] = $row['is_active'] ?? 1; // Terminal status codes don't need a target — flip the type so // the Importer's validator stops asking for one. if ( in_array( (int) $row['redirect_type'], [ 410, 451 ], true ) ) { $row['target_type'] = 'none'; } return $row; } /** * Parse a permissive boolean from a CSV cell. * * @since 4.0.3 * * @param string $value Raw cell. * * @return bool */ private function parseBool( string $value ): bool { return in_array( strtolower( trim( $value ) ), [ '1', 'true', 'yes', 'y', 'on' ], true ); } }