}>, errors: list, format: string} */ public function read( string $content, string $format = 'auto' ): array { if ( 'auto' === $format ) { $format = self::sniff( $content ); } $parsed = match ( $format ) { 'json' => self::json( $content ), 'server' => ServerRules::parse( $content ), default => Csv::parse( $content ), }; return $parsed + [ 'format' => $format ]; } /** * @return array{rows: list}>, errors: list, format: string} */ public function read_source( string $key ): array { return $this->sources->read( $key ) + [ 'format' => $key ]; } /** Which kind of file this is, from its first meaningful line. */ public static function sniff( string $content ): string { $trimmed = ltrim( $content, "\xEF\xBB\xBF \t\r\n" ); if ( str_starts_with( $trimmed, '{' ) || str_starts_with( $trimmed, '[' ) ) { return 'json'; } foreach ( preg_split( '/\R/', $trimmed ) ?: [] as $line ) { $line = trim( $line ); if ( '' === $line || str_starts_with( $line, '#' ) ) { continue; } return preg_match( '/^(Redirect(Match|Permanent|Temp)?|RewriteRule|RewriteEngine|RewriteCond|RewriteBase|rewrite|location)\s/i', $line ) ? 'server' : 'csv'; } return 'csv'; } /** * Our own export format, and any JSON list of objects with source and target keys. * * @return array{rows: list}>, errors: list} */ private static function json( string $content ): array { $data = json_decode( ltrim( $content, "\xEF\xBB\xBF" ), true ); if ( ! is_array( $data ) ) { return [ 'rows' => [], 'errors' => [ [ 'line' => 0, 'message' => __( 'That is not valid JSON.', 'codebold-redirects' ) ] ] ]; } $items = isset( $data['redirects'] ) && is_array( $data['redirects'] ) ? $data['redirects'] : $data; $rows = []; foreach ( array_values( $items ) as $index => $item ) { if ( ! is_array( $item ) || empty( $item['source'] ) ) { continue; } $allowed = [ 'source', 'target', 'match_type', 'status_code', 'case_sensitive', 'pass_query', 'conditions', 'starts_at', 'ends_at', 'enabled', 'priority', 'group_name', 'note', 'hits' ]; $input = array_intersect_key( $item, array_flip( $allowed ) ); // Exported schedules are already UTC; the form's are local time. $input['schedule_utc'] = true; // A destination that was a linked page on the old site is just an address here. if ( isset( $item['target_url'] ) && '' !== (string) $item['target_url'] ) { $input['target'] = (string) $item['target_url']; } $rows[] = [ 'line' => $index + 1, 'input' => $input ]; } return [ 'rows' => $rows, 'errors' => [] ]; } /** * Keep parsed rows for the batches that follow. Split into one transient per batch, * because a persistent object cache will refuse a single value of several megabytes * and a silently missing stash would look like an empty import. * * @param list}> $rows */ public function stash( array $rows ): string { $token = wp_generate_password( 20, false ); $chunks = array_chunk( $rows, self::BATCH ); foreach ( $chunks as $index => $chunk ) { set_transient( $this->key( $token, $index ), $chunk, HOUR_IN_SECONDS ); } set_transient( $this->key( $token, 'n' ), [ 'chunks' => count( $chunks ), 'rows' => count( $rows ) ], HOUR_IN_SECONDS ); return $token; } private function key( string $token, int|string $part ): string { return self::STASH . get_current_user_id() . '_' . substr( md5( $token ), 0, 16 ) . '_' . $part; } /** * Decide, before anything is saved, what will happen to every row. * * The same checks the import itself makes, run now, so the preview's numbers are the * import's numbers: a /wp-admin line is refused here with its reason, not counted as * "ready" and refused afterwards, and the button offers exactly what will be added. * * - ready: will be added * - exists: the same redirect is already on this site * - duplicate: the same old address appears earlier in this file * - refused: breaks a rule (a loop, the admin, not an address…), with the reason * * @param list}> $rows * @return array{ready: list}>, rows: list>, counts: array} */ public function assess( array $rows ): array { $ready = []; $out = []; $counts = [ 'ready' => 0, 'exists' => 0, 'duplicate' => 0, 'refused' => 0 ]; $seen = []; foreach ( $rows as $row ) { $input = $row['input'] + [ 'origin' => 'import' ]; $status = 'ready'; $message = ''; $key = self::same_as( $input ); if ( isset( $seen[ $key ] ) ) { $status = 'duplicate'; /* translators: %d: a line number */ $message = sprintf( __( 'Same old address as line %d.', 'codebold-redirects' ), $seen[ $key ] ); } else { $seen[ $key ] = (int) $row['line']; $checked = $this->manager->check( $input, 0, true ); if ( is_wp_error( $checked ) ) { $status = 'codebold_duplicate' === $checked->get_error_code() ? 'exists' : 'refused'; $message = 'exists' === $status ? __( 'Already on this site.', 'codebold-redirects' ) : $checked->get_error_message(); } } ++$counts[ $status ]; if ( 'ready' === $status ) { $ready[] = $row; } $out[] = [ 'line' => (int) $row['line'], 'source' => (string) $input['source'], 'target' => (string) ( $input['target'] ?? '' ), 'status_code' => (int) ( $input['status_code'] ?? 301 ), 'match_type' => (string) ( $input['match_type'] ?? 'exact' ), 'status' => $status, 'message' => $message, ]; } return [ 'ready' => $ready, 'rows' => $out, 'counts' => $counts ]; } /** * What makes two lines in one file the same redirect: the same old address, matched * the same way, for the same visitors. * * @param array $input */ private static function same_as( array $input ): string { $type = (string) ( $input['match_type'] ?? 'exact' ); $source = (string) $input['source']; if ( 'regex' !== $type ) { $address = Address::parse( $source ); $query = $address->query; ksort( $query ); $source = $address->key() . '?' . http_build_query( $query ); $type = str_contains( $source, '*' ) ? 'wildcard' : $type; } return $type . '|' . $source . '|' . wp_json_encode( $input['conditions'] ?? [] ); } /** * Import one batch. * * @return array{added: int, existing: int, invalid: list, done: bool, next: int, total: int}|WP_Error */ public function run( string $token, int $batch, string $group = '' ): array|WP_Error { $meta = get_transient( $this->key( $token, 'n' ) ); if ( ! is_array( $meta ) ) { return new WP_Error( 'codebold_import_expired', __( 'This import has expired. Please choose the file again.', 'codebold-redirects' ), [ 'status' => 410 ] ); } $rows = get_transient( $this->key( $token, $batch ) ); if ( ! is_array( $rows ) ) { $rows = []; } $report = $this->import( $rows, $group ); // Each batch is removed once done, so a retried request cannot import it twice. delete_transient( $this->key( $token, $batch ) ); $done = $batch + 1 >= (int) $meta['chunks']; if ( $done ) { delete_transient( $this->key( $token, 'n' ) ); } return $report + [ 'done' => $done, 'next' => $batch + 1, 'total' => (int) $meta['rows'] ]; } /** * @param list}> $rows * @return array{added: int, existing: int, invalid: list} */ public function import( array $rows, string $group = '' ): array { $added = 0; $existing = 0; $invalid = []; foreach ( $rows as $row ) { $input = $row['input'] + [ 'origin' => 'import' ]; if ( '' !== $group && empty( $input['group_name'] ) ) { $input['group_name'] = $group; } $result = $this->manager->create( $input, true ); if ( is_wp_error( $result ) ) { if ( 'codebold_duplicate' === $result->get_error_code() ) { ++$existing; continue; } $invalid[] = [ 'line' => (int) $row['line'], 'source' => (string) ( $input['source'] ?? '' ), 'message' => $result->get_error_message(), ]; continue; } ++$added; } return [ 'added' => $added, 'existing' => $existing, 'invalid' => $invalid ]; } }