[ 'source', 'from', 'old', 'old url', 'old_url', 'oldurl', 'url', 'origin', 'redirect from', 'redirect_from', 'source url', 'source_url', 'from url', 'from_url', 'request', 'address', 'old address' ], 'target' => [ 'target', 'to', 'new', 'new url', 'new_url', 'newurl', 'destination', 'redirect to', 'redirect_to', 'target url', 'target_url', 'to url', 'to_url', 'url_to', 'redirect', 'new address', 'destination url' ], 'code' => [ 'code', 'status', 'status code', 'status_code', 'http code', 'http_code', 'type', 'redirect type', 'redirect_type', 'header_code', 'action_code', 'http status' ], 'match' => [ 'match', 'match type', 'match_type', 'regex', 'is regex', 'kind' ], 'enabled' => [ 'enabled', 'active', 'on' ], 'group' => [ 'group', 'group name', 'group_name', 'category', 'folder' ], 'note' => [ 'note', 'notes', 'title', 'description', 'comment' ], 'hits' => [ 'hits', 'count', 'visits', 'last_count' ], 'case' => [ 'case sensitive', 'case_sensitive' ], 'query' => [ 'pass query', 'pass_query' ], 'cond' => [ 'conditions' ], ]; /** * @return array{rows: list}>, errors: list} */ public static function parse( string $content ): array { $content = self::without_bom( $content ); if ( '' === trim( $content ) ) { return [ 'rows' => [], 'errors' => [ [ 'line' => 0, 'message' => __( 'The file is empty.', 'codebold-redirects' ) ] ] ]; } $delimiter = self::delimiter( strtok( $content, "\n" ) ?: '' ); $stream = fopen( 'php://temp', 'r+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- an in-memory stream, so fgetcsv can handle quoted line breaks. if ( false === $stream ) { return [ 'rows' => [], 'errors' => [ [ 'line' => 0, 'message' => __( 'The file could not be read.', 'codebold-redirects' ) ] ] ]; } fwrite( $stream, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite rewind( $stream ); $rows = []; $errors = []; $map = null; $headless = false; $line = 0; while ( true ) { $cells = fgetcsv( $stream, 0, $delimiter, '"', '\\' ); if ( false === $cells ) { break; } ++$line; if ( [ null ] === $cells || [] === array_filter( array_map( 'trim', array_map( 'strval', $cells ) ) ) ) { continue; } $cells = array_map( static fn( $c ): string => trim( (string) $c ), $cells ); if ( null === $map ) { $header = self::header( $cells ); if ( null !== $header ) { $map = $header; continue; } // No header: the obvious reading of a plain list. $map = [ 'source' => 0, 'target' => 1, 'code' => 2 ]; $headless = true; } // Without a header nothing says which cell is an address, so a line has to look // like one. A lone word — "lorem ipsum", a heading, a stray note — would // otherwise be read as an old address with no destination: a 410 nobody meant. if ( ! empty( $headless ) && ! self::looks_like_address( $cells, $map ) ) { $errors[] = [ 'line' => $line, 'message' => __( 'Could not read this line — it does not look like an address. Old addresses start with / or https://.', 'codebold-redirects' ) ]; continue; } $input = self::row( $cells, $map ); if ( null === $input ) { $errors[] = [ 'line' => $line, 'message' => __( 'No old address on this line.', 'codebold-redirects' ) ]; continue; } $rows[] = [ 'line' => $line, 'input' => $input ]; } fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return [ 'rows' => $rows, 'errors' => $errors ]; } /** * Whether a headerless line's first cell is plausibly an old address: a path, a full * URL, or a pattern — and, when it stands alone, not a sentence. * * @param list $cells * @param array $map */ private static function looks_like_address( array $cells, array $map ): bool { $source = trim( (string) ( $cells[ $map['source'] ] ?? '' ) ); if ( '' === $source || preg_match( '/\s/', $source ) ) { return false; } $filled = count( array_filter( $cells, static fn( string $c ): bool => '' !== trim( $c ) ) ); // With a destination beside it, "old-page,new-page" is still clearly a pair of // addresses. Alone, it has to start like one. if ( $filled > 1 ) { return true; } return (bool) preg_match( '#^(/|\^|\*|https?://)#i', $source ); } private static function without_bom( string $content ): string { return str_starts_with( $content, "\xEF\xBB\xBF" ) ? substr( $content, 3 ) : $content; } private static function delimiter( string $first ): string { $counts = [ ',' => substr_count( $first, ',' ), ';' => substr_count( $first, ';' ), "\t" => substr_count( $first, "\t" ), '|' => substr_count( $first, '|' ), ]; arsort( $counts ); $best = (string) array_key_first( $counts ); return $counts[ $best ] > 0 ? $best : ','; } /** * Column positions, if this row is a header — it must name at least the old address. * * @param list $cells * @return array|null */ private static function header( array $cells ): ?array { $map = []; foreach ( $cells as $index => $cell ) { $name = strtolower( trim( str_replace( [ '-', '"' ], [ ' ', '' ], $cell ) ) ); foreach ( self::COLUMNS as $key => $aliases ) { if ( ! isset( $map[ $key ] ) && in_array( $name, $aliases, true ) ) { $map[ $key ] = $index; break; } } } // "url" alone could be either side. With both a source and a target column found, // the header is unambiguous; with only a source, it is still clearly a header if // the first cell does not look like an address. if ( isset( $map['source'] ) && ( isset( $map['target'] ) || ! str_contains( $cells[0], '/' ) ) ) { return $map; } return null; } /** * @param list $cells * @param array $map * @return array|null */ private static function row( array $cells, array $map ): ?array { $get = static fn( string $key ): string => isset( $map[ $key ] ) ? (string) ( $cells[ $map[ $key ] ] ?? '' ) : ''; $source = $get( 'source' ); if ( '' === $source ) { return null; } $input = [ 'source' => $source, 'target' => $get( 'target' ), ]; $code_cell = $get( 'code' ); $match_cell = strtolower( $get( 'match' ) ); // "type" is a code in some exports and a match type in others; the value decides. if ( '' !== $code_cell && ! is_numeric( $code_cell ) && in_array( strtolower( $code_cell ), [ 'regex', 'wildcard', 'exact', 'plain' ], true ) ) { $match_cell = strtolower( $code_cell ); $code_cell = ''; } $input['status_code'] = self::code( $code_cell, '' === $input['target'] ); $input['match_type'] = self::match( $match_cell ); if ( '' !== $get( 'enabled' ) ) { $input['enabled'] = ! in_array( strtolower( $get( 'enabled' ) ), [ '0', 'no', 'false', 'off', 'disabled', 'inactive' ], true ); } foreach ( [ 'group' => 'group_name', 'note' => 'note' ] as $column => $field ) { if ( '' !== $get( $column ) ) { $input[ $field ] = $get( $column ); } } if ( is_numeric( $get( 'hits' ) ) ) { $input['hits'] = (int) $get( 'hits' ); } if ( '' !== $get( 'case' ) ) { $input['case_sensitive'] = in_array( strtolower( $get( 'case' ) ), [ '1', 'yes', 'true' ], true ); } if ( '' !== $get( 'query' ) ) { $input['pass_query'] = in_array( strtolower( $get( 'query' ) ), [ '1', 'yes', 'true' ], true ); } if ( '' !== $get( 'cond' ) ) { $conditions = json_decode( $get( 'cond' ), true ); if ( is_array( $conditions ) ) { $input['conditions'] = $conditions; } } return $input; } public static function code( string $value, bool $no_target = false ): int { $value = strtolower( trim( $value ) ); if ( is_numeric( $value ) ) { return (int) $value; } return match ( $value ) { 'permanent', 'moved permanently', 'permanently' => 301, 'temp', 'temporary', 'found', 'moved temporarily' => 302, 'gone', 'deleted' => 410, default => $no_target ? 410 : 301, }; } private static function match( string $value ): string { return match ( $value ) { 'regex', '1', 'true', 'yes', 'regexp' => 'regex', 'wildcard', 'pattern', 'starts with', 'start' => 'wildcard', default => 'exact', }; } /** * Write rules as CSV. A header row the parser above reads back. * * @param list> $rows */ public static function write( array $rows ): string { $stream = fopen( 'php://temp', 'r+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( false === $stream ) { return ''; } $columns = [ 'source', 'target', 'code', 'match', 'enabled', 'group', 'note', 'hits', 'case_sensitive', 'pass_query', 'conditions' ]; fputcsv( $stream, $columns, ',', '"', '\\' ); foreach ( $rows as $row ) { $line = []; foreach ( $columns as $column ) { $value = $row[ $column ] ?? ''; // A cell starting with = + - @ is run as a formula by Excel. Prefixing an // apostrophe stops a crafted address from becoming one. if ( is_string( $value ) && '' !== $value && in_array( $value[0], [ '=', '+', '-', '@' ], true ) ) { $value = "'" . $value; } $line[] = is_bool( $value ) ? (int) $value : $value; } fputcsv( $stream, $line, ',', '"', '\\' ); } rewind( $stream ); $out = (string) stream_get_contents( $stream ); fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $out; } }