Csv::BYTES || ! preg_match( '//u', $bytes ) ) { throw new \DomainException( 'import_source_format' ); } if ( str_starts_with( $bytes, "\xef\xbb\xbf" ) ) { $bytes = substr( $bytes, 3 ); } try { $root = json_decode( $bytes, false, 8, JSON_THROW_ON_ERROR ); } catch ( \JsonException $error ) { throw new \DomainException( 'import_source_format' ); } $offset = 0; self::unique_keys( $bytes, $offset ); if ( ! $root instanceof \stdClass ) { throw new \DomainException( 'import_source_format' ); } $root = (array) $root; $keys = array_keys( $root ); sort( $keys ); if ( $keys !== array( 'appointment_meta', 'appointments', 'resource_appointments', 'staff_appointments' ) ) { throw new \DomainException( 'import_source_format' ); } if ( $root['appointment_meta'] !== array() ) { throw new \DomainException( 'import_source_format' ); } // SSA_Missing::__call returns null for absent premium root models in Basic. foreach ( array( 'staff_appointments', 'resource_appointments' ) as $key ) { if ( $root[ $key ] !== array() && $root[ $key ] !== null ) { throw new \DomainException( 'import_source_format' ); } } $appointments = $root['appointments']; if ( ! is_array( $appointments ) || array_values( $appointments ) !== $appointments || ! $appointments || count( $appointments ) > Csv::ROWS ) { throw new \DomainException( 'import_rows' ); } $contacts = array(); $identities = array(); $ids = array(); foreach ( $appointments as $appointment ) { if ( ! $appointment instanceof \stdClass ) { throw new \DomainException( 'import_source_format' ); } $row = (array) $appointment; if ( array_diff( array_keys( $row ), self::FIELDS ) || array_diff( array( 'id', 'appointment_type_id', 'customer_information', 'start_date', 'end_date', 'status' ), array_keys( $row ) ) ) { throw new \DomainException( 'import_source_format' ); } $id = Service::integer( $row['id'], 1 ); Service::integer( $row['appointment_type_id'], 1 ); if ( isset( $ids[ $id ] ) ) { throw new \DomainException( 'import_duplicate_source' ); } $ids[ $id ] = true; // Native prepare_item_for_response enriches every exported row, even in Basic. // Disabled paid features return [] here, not the root-level Missing-model null. foreach ( array( 'staff_ids', 'selected_resources', 'meta', 'payments' ) as $key ) { if ( array_key_exists( $key, $row ) && $row[ $key ] !== array() ) { throw new \DomainException( 'import_source_format' ); } } foreach ( array( 'public_edit_url', 'public_token', 'rescheduling_note' ) as $key ) { if ( array_key_exists( $key, $row ) && ! is_string( $row[ $key ] ) ) { throw new \DomainException( 'import_source_format' ); } } foreach ( $row as $key => $value ) { if ( in_array( $key, array( 'customer_information', 'staff_ids', 'selected_resources', 'meta', 'payments' ), true ) ) { continue; } if ( ! is_scalar( $value ) && $value !== null || is_string( $value ) && ( strlen( $value ) > Csv::CELL || preg_match( '/[\x00-\x1f]/', $value ) ) ) { throw new \DomainException( 'import_source_format' ); } } foreach ( array( 'group_id', 'payment_method', 'payment_received', 'mailchimp_list_id', 'google_calendar_id', 'google_calendar_event_id', 'web_meeting_password', 'web_meeting_id', 'web_meeting_url', 'allow_sms' ) as $key ) { if ( isset( $row[ $key ] ) && ! in_array( $row[ $key ], array( '', false, 0, '0', '0.00' ), true ) ) { throw new \DomainException( 'import_source_format' ); } } foreach ( array( 'start_date', 'end_date' ) as $key ) { $date = $row[ $key ]; if ( ! is_string( $date ) ) { throw new \DomainException( 'import_source_format' ); } $instant = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $date, new \DateTimeZone( 'UTC' ) ); if ( ! $instant || $instant->format( 'Y-m-d H:i:s' ) !== $date ) { throw new \DomainException( 'import_source_format' ); } } if ( ! is_string( $row['status'] ) || ! in_array( $row['status'], array( 'booked', 'canceled', 'abandoned', 'pending_form' ), true ) ) { throw new \DomainException( 'import_source_format' ); } $information = $row['customer_information']; if ( ! $information instanceof \stdClass ) { throw new \DomainException( 'import_customer' ); } $information = (array) $information; if ( array_diff( array_keys( $information ), array( 'Name', 'Email', 'Phone' ) ) || ! isset( $information['Name'] ) ) { throw new \DomainException( 'import_source_format' ); } $contact = array( 'source_key' => 'contact', 'display_name' => $information['Name'], 'email' => $information['Email'] ?? '', 'phone' => $information['Phone'] ?? '' ); $normalized = Service::normalize( 'customers', $contact )['customer']; $identity = $normalized['email'] !== '' ? 'email:' . $normalized['email'] : 'phone:' . $normalized['phone']; $key = 'ssa-contact-' . hash( 'sha256', $identity ); if ( isset( $contacts[ $key ] ) ) { if ( $contacts[ $key ]['normalized'] !== $normalized ) { throw new \DomainException( 'import_ambiguous_contact' ); } continue; } foreach ( array( 'email', 'phone' ) as $field ) { if ( $normalized[ $field ] === '' ) { continue; } $identifier = $field . ':' . $normalized[ $field ]; if ( isset( $identities[ $identifier ] ) && $identities[ $identifier ] !== $key ) { throw new \DomainException( 'import_ambiguous_contact' ); } $identities[ $identifier ] = $key; } $contact = array( 'source_key' => $key, 'display_name' => $normalized['display_name'], 'email' => $normalized['email'], 'phone' => $normalized['phone'] ); $contacts[ $key ] = array( 'contact' => $contact, 'normalized' => $normalized ); } // Stable output independent of source row order, appointment IDs or booking count. ksort( $contacts ); $stream = fopen( 'php://temp', 'w+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- In-memory php://temp stream for fputcsv(); no file is written. if ( ! $stream ) { throw new \RuntimeException( 'import_storage' ); } try { if ( fputcsv( $stream, array( 'source_key', 'display_name', 'email', 'phone' ), ',', '"', '' ) === false ) { throw new \RuntimeException( 'import_storage' ); } foreach ( $contacts as $item ) { if ( fputcsv( $stream, array_values( $item['contact'] ), ',', '"', '' ) === false ) { throw new \RuntimeException( 'import_storage' ); } } if ( ! rewind( $stream ) ) { throw new \RuntimeException( 'import_storage' ); } $csv = stream_get_contents( $stream ); if ( ! is_string( $csv ) ) { throw new \RuntimeException( 'import_storage' ); } Csv::parse( $csv ); return array( 'csv' => $csv, 'metadata' => array( 'format' => 'ssa_free_contacts_v1', 'kind' => 'customers', 'edition' => 'shared', 'scope' => 'contacts_only', 'source_appointments' => count( $appointments ), 'contacts' => count( $contacts ) ) ); } finally { fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the in-memory php://temp stream. } } /** json_decode otherwise silently overwrites duplicate (including escaped) object keys. */ private static function unique_keys( string $json, int &$offset ): void { self::space( $json, $offset ); $char = $json[ $offset ]; if ( $char === '{' ) { ++$offset; self::space( $json, $offset ); $keys = array(); if ( $json[ $offset ] === '}' ) { ++$offset; return; } while ( true ) { $key = self::string( $json, $offset ); if ( isset( $keys[ $key ] ) ) { throw new \DomainException( 'import_source_format' ); } $keys[ $key ] = true; self::space( $json, $offset ); ++$offset; self::unique_keys( $json, $offset ); self::space( $json, $offset ); $char = $json[ $offset++ ]; if ( $char === '}' ) { return; } self::space( $json, $offset ); } } if ( $char === '[' ) { ++$offset; self::space( $json, $offset ); if ( $json[ $offset ] === ']' ) { ++$offset; return; }while ( true ) { self::unique_keys( $json, $offset ); self::space( $json, $offset ); if ( $json[ $offset++ ] === ']' ) { return; } } } if ( $char === '"' ) { self::string( $json, $offset ); return; } $length = strlen( $json ); while ( $offset < $length && ! str_contains( ",]} \t\r\n", $json[ $offset ] ) ) { ++$offset; } } private static function space( string $json, int &$offset ): void { $length = strlen( $json ); while ( $offset < $length && str_contains( " \t\r\n", $json[ $offset ] ) ) { ++$offset; } } private static function string( string $json, int &$offset ): string { $start = $offset++; $length = strlen( $json ); while ( $offset < $length ) { $char = $json[ $offset++ ]; if ( $char === '\\' ) { ++$offset; } elseif ( $char === '"' ) { return json_decode( substr( $json, $start, $offset - $start ), true, 8, JSON_THROW_ON_ERROR ); } } throw new \DomainException( 'import_source_format' ); } }