self::BYTES || ! preg_match( '//u', $csv ) || strpos( $csv, "\0" ) !== false || ! in_array( $delimiter, array( ',', ';', '\t' ), true ) ) { throw new \DomainException( 'import_csv' ); } $file_digest = hash( 'sha256', $csv ); if ( $delimiter === '\t' ) { $delimiter = "\t"; } if ( str_starts_with( $csv, "\xef\xbb\xbf" ) ) { $csv = substr( $csv, 3 ); } // fgetcsv accepts unterminated quotes; reject that and trailing garbage explicitly. self::lex( $csv, $delimiter ); $stream = fopen( 'php://temp', 'w+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- In-memory php://temp stream for fgetcsv(); no file is written. if ( ! $stream ) { throw new \RuntimeException( 'import_csv' ); } try { if ( fwrite( $stream, $csv ) !== strlen( $csv ) || ! rewind( $stream ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writes to the in-memory php://temp stream. throw new \RuntimeException( 'import_csv' ); } $rows = array();while ( ( $row = fgetcsv( $stream, self::BYTES + 1, $delimiter, '"', '' ) ) !== false ) { if ( count( $row ) > self::COLUMNS ) { throw new \DomainException( 'import_columns' ); } foreach ( $row as $cell ) { if ( ! is_string( $cell ) || strlen( $cell ) > self::CELL || preg_match( '/[\x00-\x08\x0b\x0c\x0e-\x1f]/', $cell ) || ( preg_match( '/^\s*[=@]/u', $cell ) || ( preg_match( '/^\s*\+/u', $cell ) && ! preg_match( '/^\s*\+[0-9 ().-]+\s*$/D', $cell ) ) ) ) { throw new \DomainException( 'import_cell' ); } } $rows[] = $row; if ( count( $rows ) > self::ROWS + 1 ) { throw new \DomainException( 'import_rows' ); } } if ( count( $rows ) < 2 ) { throw new \DomainException( 'import_rows' ); } $headers = array_shift( $rows ); if ( in_array( '', array_map( 'trim', $headers ), true ) || count( array_unique( $headers ) ) !== count( $headers ) ) { throw new \DomainException( 'import_header' ); } foreach ( $rows as $row ) { if ( count( $row ) !== count( $headers ) ) { throw new \DomainException( 'import_columns' ); } } return array( 'headers' => $headers, 'rows' => $rows, 'file_digest' => $file_digest ); } finally { fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the in-memory php://temp stream. } } private static function lex( string $csv, string $delimiter ): void { $state = 'start'; $length = strlen( $csv );for ( $i = 0;$i < $length;++$i ) { $char = $csv[ $i ]; if ( $state === 'quoted' ) { if ( $char === '"' ) { if ( $i + 1 < $length && $csv[ $i + 1 ] === '"' ) { ++$i; } else { $state = 'closed'; } } continue; } if ( $char === $delimiter || $char === "\r" || $char === "\n" ) { $state = 'start'; continue; } if ( $state === 'closed' ) { throw new \DomainException( 'import_csv' ); } if ( $char === '"' ) { if ( $state !== 'start' ) { throw new \DomainException( 'import_csv' ); } $state = 'quoted'; } else { $state = 'plain'; } } if ( $state === 'quoted' ) { throw new \DomainException( 'import_csv' ); } } public static function fields( string $kind ): array { if ( $kind === 'customers' ) { return array( 'source_key', 'display_name', 'email', 'phone' ); } if ( $kind === 'services' ) { return array( 'source_key', 'name', 'slug', 'duration_minutes', 'price_cents', 'currency', 'buffer_before_minutes', 'buffer_after_minutes' ); } if ( $kind === 'appointments' ) { return array( 'source_key', 'display_name', 'email', 'phone', 'service_key', 'staff_key', 'location_key', 'start_utc', 'duration_minutes', 'price_cents', 'currency', 'timezone', 'status_slug' ); } throw new \DomainException( 'import_kind' ); } public static function map( array $parsed, string $kind, array $mapping ): array { $fields = self::fields( $kind ); if ( count( $mapping ) !== count( $parsed['headers'] ) ) { throw new \DomainException( 'import_mapping' ); } $used = array(); foreach ( $mapping as $column => $field ) { if ( ! preg_match( '/^(0|[1-9][0-9]*)$/D', (string) $column ) || (int) $column >= count( $parsed['headers'] ) || ! is_string( $field ) || ( $field !== 'ignore' && ! in_array( $field, $fields, true ) ) || ( $field !== 'ignore' && isset( $used[ $field ] ) ) ) { throw new \DomainException( 'import_mapping' ); } if ( $field !== 'ignore' ) { $used[ $field ] = true; } } $required = $kind === 'customers' ? array( 'source_key', 'display_name' ) : ( $kind === 'services' ? array( 'source_key', 'name', 'slug', 'duration_minutes', 'price_cents', 'currency' ) : array( 'source_key', 'display_name', 'service_key', 'staff_key', 'location_key', 'start_utc', 'duration_minutes', 'price_cents', 'currency', 'timezone', 'status_slug' ) ); if ( array_diff( $required, array_keys( $used ) ) ) { throw new \DomainException( 'import_mapping' ); } $out = array(); foreach ( $parsed['rows'] as $row ) { $item = array_fill_keys( $fields, '' ); foreach ( $mapping as $column => $field ) { if ( $field !== 'ignore' ) { $item[ $field ] = $row[ (int) $column ]; } } $out[] = $item; } return $out; } /** Defense for any future exported cell. Imports never evaluate formulas. */ public static function spreadsheet_cell( string $cell ): string { return preg_match( '/^\s*[=+\-@]/u', $cell ) ? "'" . $cell : $cell; } }