, sample:array>, error?:string} */ public static function parse( string $content, string $format ): array { if ( strlen( $content ) > self::MAX_BYTES ) { return array( 'rows' => 0, 'columns' => array(), 'sample' => array(), 'error' => __( 'The file is larger than 8 MB.', 'ai-rank-suite' ) ); } $rows = 'json' === $format ? self::json_rows( $content ) : self::csv_rows( $content ); if ( isset( $rows['error'] ) ) { return array( 'rows' => 0, 'columns' => array(), 'sample' => array(), 'error' => (string) $rows['error'] ); } $columns = array(); foreach ( $rows as $row ) { foreach ( array_keys( $row ) as $key ) { $columns[ (string) $key ] = true; } } return array( 'rows' => count( $rows ), 'columns' => array_keys( $columns ), 'sample' => array_slice( $rows, 0, 3 ), ); } /** * Every row of the file, as string maps. * * @return array> */ public static function rows( string $content, string $format ): array { // The same ceiling parse() enforces. Without it, a POST straight to // /import/start skipped the preview and put an unbounded file into a // single options row — the write fails past max_allowed_packet, reads // back as "no rows", and the import reports success having changed // nothing. if ( strlen( $content ) > self::MAX_BYTES ) { return array(); } $rows = 'json' === $format ? self::json_rows( $content ) : self::csv_rows( $content ); return isset( $rows['error'] ) ? array() : $rows; } /** * @return array>|array{error: string} */ private static function csv_rows( string $content ): array { $content = self::strip_bom( $content ); // php://temp is an in-memory stream, used only to hand the uploaded CSV // text to fgetcsv(). No file on disk is opened; WP_Filesystem has no // equivalent for this. // phpcs:disable WordPress.WP.AlternativeFunctions $handle = fopen( 'php://temp', 'r+' ); if ( ! $handle ) { return array( 'error' => __( 'The file could not be read.', 'ai-rank-suite' ) ); } fwrite( $handle, $content ); rewind( $handle ); $delimiter = self::delimiter( $content ); $header = fgetcsv( $handle, 0, $delimiter ); if ( ! is_array( $header ) ) { fclose( $handle ); return array( 'error' => __( 'The file has no header row.', 'ai-rank-suite' ) ); } $header = array_map( static fn( $h ) => strtolower( trim( (string) $h ) ), $header ); $out = array(); while ( ( $line = fgetcsv( $handle, 0, $delimiter ) ) !== false ) { if ( ! is_array( $line ) || ( 1 === count( $line ) && ( null === $line[0] || '' === trim( (string) $line[0] ) ) ) ) { continue; } $row = array(); foreach ( $header as $i => $name ) { if ( '' === $name ) { continue; } $row[ $name ] = trim( (string) ( $line[ $i ] ?? '' ) ); } $out[] = $row; if ( count( $out ) >= self::MAX_ROWS ) { break; } } fclose( $handle ); // phpcs:enable WordPress.WP.AlternativeFunctions return $out; } /** * @return array>|array{error: string} */ private static function json_rows( string $content ): array { $decoded = json_decode( self::strip_bom( $content ), true ); if ( ! is_array( $decoded ) ) { return array( 'error' => __( 'The file is not valid JSON.', 'ai-rank-suite' ) ); } // Accept both a bare list and {"items": [...]} / {"rows": [...]}. if ( isset( $decoded['items'] ) && is_array( $decoded['items'] ) ) { $decoded = $decoded['items']; } elseif ( isset( $decoded['rows'] ) && is_array( $decoded['rows'] ) ) { $decoded = $decoded['rows']; } $out = array(); foreach ( $decoded as $row ) { if ( ! is_array( $row ) ) { continue; } $clean = array(); foreach ( $row as $key => $value ) { $clean[ strtolower( trim( (string) $key ) ) ] = is_scalar( $value ) ? trim( (string) $value ) : ( is_array( $value ) ? implode( ',', array_map( 'strval', $value ) ) : '' ); } $out[] = $clean; if ( count( $out ) >= self::MAX_ROWS ) { break; } } return $out; } private static function strip_bom( string $content ): string { return str_starts_with( $content, "\xEF\xBB\xBF" ) ? substr( $content, 3 ) : $content; } /** Excel in a German locale writes semicolons; a tab export writes tabs. */ private static function delimiter( string $content ): string { $line = strtok( $content, "\n" ); $line = false === $line ? '' : $line; $best = ','; $max = substr_count( $line, ',' ); foreach ( array( ';' => substr_count( $line, ';' ), "\t" => substr_count( $line, "\t" ) ) as $char => $count ) { if ( $count > $max ) { $best = $char; $max = $count; } } return $best; } /** * Which post a row is about. Rows are matched by post id, by URL or by * slug — whichever column the customer says holds it. */ public static function match( array $row, string $by ): int { $value = trim( (string) ( $row[ $by ] ?? $row['id'] ?? $row['url'] ?? $row['slug'] ?? '' ) ); if ( '' === $value ) { return 0; } if ( 'id' === $by || ctype_digit( $value ) ) { $id = (int) $value; return $id > 0 && get_post_status( $id ) ? $id : 0; } if ( 'slug' === $by ) { // suppress_filters on purpose: this lookup maps an imported URL to its // post in ANY language — a multilingual plugin filtering the query to the // current language would make half the rows in a handover file unmatchable. // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters $posts = get_posts( array( 'name' => sanitize_title( $value ), 'post_type' => 'any', 'post_status' => 'any', 'numberposts' => 1, 'fields' => 'ids', 'suppress_filters' => true ) ); return ! empty( $posts ) ? (int) $posts[0] : 0; } // URL: url_to_postid() understands permalinks, and a path-only value // gets the site's own host so a relative column still resolves. if ( ! preg_match( '#^https?://#i', $value ) ) { $value = home_url( '/' . ltrim( $value, '/' ) ); } return (int) url_to_postid( $value ); } /** * Turn one row into an import record using the column mapping. * * @param array $row * @param array $map Column name => field (title, desc, keywords, canonical, robots, og_title, ...). * @return array */ public static function record( array $row, array $map ): array { $record = array(); $social = array(); foreach ( $map as $column => $field ) { $value = trim( (string) ( $row[ strtolower( $column ) ] ?? '' ) ); if ( '' === $value || '' === $field ) { continue; } switch ( $field ) { case 'keywords': $record['keywords'] = array_values( array_filter( array_map( 'trim', explode( ',', $value ) ) ) ); break; case 'robots': $record['robots'] = array_values( array_intersect( array( 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex' ), array_map( 'trim', explode( ',', strtolower( $value ) ) ) ) ); break; case 'exclude_sitemap': $record['exclude_sitemap'] = in_array( strtolower( $value ), array( '1', 'yes', 'true', 'on' ), true ); break; case 'og_title': case 'og_desc': case 'og_image': case 'tw_title': case 'tw_desc': case 'tw_image': $social[ $field ] = $value; break; case 'redirect': $record['redirect'] = array( 'target' => $value, 'type' => 301 ); break; default: $record[ $field ] = $value; } } if ( ! empty( $social ) ) { $record['social'] = $social; } return $record; } /** * The fields a column can be mapped onto, with a label for the screen. * * @return array */ public static function fields(): array { return array( 'title' => __( 'SEO title', 'ai-rank-suite' ), 'desc' => __( 'Meta description', 'ai-rank-suite' ), 'keywords' => __( 'Keywords (comma separated)', 'ai-rank-suite' ), 'canonical' => __( 'Canonical URL', 'ai-rank-suite' ), 'robots' => __( 'Robots (noindex, nofollow …)', 'ai-rank-suite' ), 'breadcrumb_title' => __( 'Breadcrumb title', 'ai-rank-suite' ), 'schema_type' => __( 'Schema type', 'ai-rank-suite' ), 'exclude_sitemap' => __( 'Exclude from the sitemap', 'ai-rank-suite' ), 'og_title' => __( 'Open Graph title', 'ai-rank-suite' ), 'og_desc' => __( 'Open Graph description', 'ai-rank-suite' ), 'og_image' => __( 'Open Graph image', 'ai-rank-suite' ), 'tw_title' => __( 'X (Twitter) title', 'ai-rank-suite' ), 'tw_desc' => __( 'X (Twitter) description', 'ai-rank-suite' ), 'tw_image' => __( 'X (Twitter) image', 'ai-rank-suite' ), 'redirect' => __( 'Redirect target', 'ai-rank-suite' ), ); } }