Diagnostics > Data management > Export data). * Source: Bookly 28.7 Backend\Modules\Diagnostics\Ajax::exportData, Lib\Entities schemas. * Reads customers, simple services, staff links and customer appointments only. Bookly * options (including credentials when "safe" was not ticked) are never read except the * payment currency code; payments, packages, series and add-on tables are reported, not imported. * Appointment times are Bookly site wall times; the caller supplies that site's timezone. */ namespace Abientot\Import\SourceAdapters; use Abientot\Import\{Csv, Service}; if ( ! defined( 'ABSPATH' ) ) { exit; } final class Bookly { public const BYTES = 10485760; public const PLUGIN = 'bookly-responsive-appointment-booking-tool/main.php'; private const CORE = 'Bookly\\Lib\\Entities\\'; private const STATUS = array( 'pending' => 'pending', 'approved' => 'confirmed', 'done' => 'completed', 'cancelled' => 'cancelled', 'rejected' => 'cancelled' ); /** Core tables that hold booking data this adapter does not carry, reported with counts. */ private const UNCARRIED = array( 'Payment', 'Order', 'Series', 'SubService', 'Holiday', 'MailingList', 'MailingCampaign' ); public static function analyze( string $bytes, string $timezone, string $currency, ?int $now = null ): array { if ( $bytes === '' || strlen( $bytes ) > self::BYTES || ! preg_match( '//u', $bytes ) ) { throw new \DomainException( 'import_source_format' ); } if ( str_starts_with( $bytes, "\xef\xbb\xbf" ) ) { $bytes = substr( $bytes, 3 ); } if ( $timezone !== 'UTC' && ! in_array( $timezone, \DateTimeZone::listIdentifiers(), true ) ) { throw new \DomainException( 'import_timezone' ); } if ( ! preg_match( '/^[A-Z]{3}$/D', $currency ) ) { throw new \DomainException( 'import_currency' ); } try { $root = json_decode( $bytes, true, 64, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING ); } catch ( \JsonException $error ) { throw new \DomainException( 'import_source_format' ); } unset( $bytes ); if ( ! is_array( $root ) || ! is_array( $root['plugins'] ?? null ) || ! is_array( $root['entities'] ?? null ) || ! is_string( $root['plugins'][ self::PLUGIN ] ?? null ) ) { throw new \DomainException( 'import_source_format' ); } $now = $now ?? time(); $zone = new \DateTimeZone( $timezone ); $version = self::text( $root['plugins'][ self::PLUGIN ], 20 ); $addons = array(); foreach ( $root['plugins'] as $basename => $plugin_version ) { if ( is_string( $basename ) && is_string( $plugin_version ) && preg_match( '#^(bookly-addon-[a-z0-9-]{1,60})/main\.php$#D', $basename, $match ) ) { $addons[ $match[1] ] = self::text( $plugin_version, 20 ); } } ksort( $addons ); $source_currency = $root['options']['bookly_pmt_currency'] ?? ''; $source_currency = is_string( $source_currency ) && preg_match( '/^[A-Z]{3}$/D', $source_currency ) ? $source_currency : ''; unset( $root['options'] ); $tables = array(); $other = array(); foreach ( $root['entities'] as $class => $table ) { if ( ! is_string( $class ) || ! is_array( $table ) || ! is_array( $table['fields'] ?? null ) || ! is_array( $table['values'] ?? null ) ) { throw new \DomainException( 'import_source_format' ); } if ( str_starts_with( $class, self::CORE ) ) { $tables[ substr( $class, strlen( self::CORE ) ) ] = $table; } elseif ( preg_match( '/^[A-Za-z0-9_\\\\]{1,190}$/D', $class ) ) { $other[ $class ] = array( 'rows' => count( $table['values'] ), 'fields' => array_values( array_filter( $table['fields'], static fn( $field ) => is_string( $field ) && preg_match( '/^[a-z0-9_]{1,64}$/D', $field ) ) ) ); } } unset( $root ); foreach ( array( 'Customer', 'Service', 'Staff', 'StaffService', 'Appointment', 'CustomerAppointment' ) as $required ) { if ( ! isset( $tables[ $required ] ) ) { throw new \DomainException( 'import_source_format' ); } } $skipped = array(); $skip = static function ( string $type, string $label, string $reason ) use ( &$skipped ): void { $skipped[] = array( 'type' => $type, 'label' => $label, 'reason' => $reason ); }; $staff = array(); foreach ( self::rows( $tables['Staff'], array( 'id', 'full_name' ) ) as $row ) { $id = self::id( $row['id'] ); if ( $id ) { $staff[ $id ] = self::text( $row['full_name'] ?? '', 190 ); } } $customers = array(); $contacts = array(); foreach ( self::rows( $tables['Customer'], array( 'id', 'full_name', 'phone', 'email' ) ) as $row ) { $id = self::id( $row['id'] ); if ( ! $id ) { continue; } $name = self::text( $row['full_name'] ?? '', 190 ); if ( $name === '' ) { $name = self::text( trim( (string) ( $row['first_name'] ?? '' ) . ' ' . (string) ( $row['last_name'] ?? '' ) ), 190 ); } $email = strtolower( self::text( $row['email'] ?? '', 190 ) ); $phone = self::text( $row['phone'] ?? '', 80 ); $notes = array(); if ( $email !== '' && ( ! is_email( $email ) || sanitize_email( $email ) !== $email || ! self::cell_safe( $email ) ) ) { $email = ''; $notes[] = 'email'; } if ( $phone !== '' && ! preg_match( '/^\+?[0-9 ().-]{3,80}$/D', $phone ) ) { $phone = ''; $notes[] = 'phone'; } $label = $name !== '' ? $name : sprintf( 'Bookly #%d', $id ); if ( $name === '' ) { $skip( 'customer', $label, 'customer_name' ); continue; } if ( $email === '' && $phone === '' ) { $skip( 'customer', $label, 'customer_contact' ); continue; } if ( ! self::cell_safe( $name ) ) { $skip( 'customer', $label, 'customer_cell' ); continue; } foreach ( $notes as $note ) { $skip( 'customer_field', $label, 'customer_' . $note . '_dropped' ); } $customers[ $id ] = array( 'source_key' => 'c' . $id, 'display_name' => $name, 'email' => $email, 'phone' => $phone ); $contacts[ $id ] = $customers[ $id ]; } $services = array(); $used = array(); foreach ( self::rows( $tables['Service'], array( 'id', 'title', 'duration', 'price' ) ) as $row ) { $id = self::id( $row['id'] ); if ( ! $id ) { continue; } $title = self::text( $row['title'] ?? '', 190 ); $label = $title !== '' ? $title : sprintf( 'Bookly #%d', $id ); $type = (string) ( $row['type'] ?? 'simple' ); if ( $type !== 'simple' ) { $skip( 'service', $label, 'service_type' ); continue; } $minutes = intdiv( (int) $row['duration'], 60 ); if ( $title === '' || ! self::cell_safe( $title ) || $minutes < 1 || $minutes > 1440 || (int) $row['duration'] % 60 !== 0 ) { $skip( 'service', $label, 'service_value' ); continue; } $slug = sanitize_title( $title ); if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug ) || strlen( $slug ) > 150 ) { $slug = 'bookly-service-' . $id; } $base = $slug; for ( $n = 2; isset( $used[ $slug ] ); ++$n ) { $slug = $base . '-' . $n; } $used[ $slug ] = true; $services[ $id ] = array( 'row' => array( 'source_key' => 's' . $id, 'name' => $title, 'slug' => $slug, 'duration_minutes' => $minutes, 'price_cents' => self::cents( $row['price'] ?? '0' ), 'currency' => $currency, 'buffer_before_minutes' => min( 1440, intdiv( max( 0, (int) ( $row['padding_left'] ?? 0 ) ), 60 ) ), 'buffer_after_minutes' => min( 1440, intdiv( max( 0, (int) ( $row['padding_right'] ?? 0 ) ), 60 ) ) ), 'staff' => array(), 'capacity' => max( 1, (int) ( $row['capacity_max'] ?? 1 ) ), 'label' => $title ); } foreach ( self::rows( $tables['StaffService'], array( 'staff_id', 'service_id' ) ) as $row ) { $service = self::id( $row['service_id'] ); $member = self::id( $row['staff_id'] ); if ( isset( $services[ $service ], $staff[ $member ] ) && ! in_array( $member, $services[ $service ]['staff'], true ) ) { $services[ $service ]['staff'][] = $member; } } $appointments = array(); foreach ( self::rows( $tables['Appointment'], array( 'id', 'staff_id', 'service_id', 'start_date', 'end_date' ) ) as $row ) { $id = self::id( $row['id'] ); if ( $id ) { $appointments[ $id ] = $row; } } $out = array(); $stamp = gmdate( 'Y-m-d H:i:s', $now ); foreach ( self::rows( $tables['CustomerAppointment'], array( 'id', 'customer_id', 'appointment_id', 'status' ) ) as $row ) { $id = self::id( $row['id'] ); if ( ! $id ) { continue; } $appointment = $appointments[ self::id( $row['appointment_id'] ) ] ?? null; $customer = $contacts[ self::id( $row['customer_id'] ) ] ?? null; $service = $appointment ? ( $services[ self::id( $appointment['service_id'] ) ] ?? null ) : null; $label = sprintf( 'Bookly #%d', $id ) . ( $customer ? ' · ' . $customer['display_name'] : '' ) . ( $appointment ? ' · ' . self::text( (string) $appointment['start_date'], 19 ) : '' ); $status = self::STATUS[ (string) $row['status'] ] ?? null; if ( ! $appointment ) { $skip( 'appointment', $label, 'appointment_missing' ); continue; } if ( $status === null ) { $skip( 'appointment', $label, (string) $row['status'] === 'waitlisted' ? 'appointment_waitlisted' : 'appointment_status' ); continue; } if ( ! $customer ) { $skip( 'appointment', $label, 'appointment_customer' ); continue; } if ( ! $service ) { $skip( 'appointment', $label, self::id( $appointment['service_id'] ) ? 'appointment_service' : 'appointment_custom_service' ); continue; } foreach ( array( 'compound_service_id', 'collaborative_service_id' ) as $linked ) { if ( ! empty( $row[ $linked ] ) ) { $skip( 'appointment', $label, 'appointment_linked_service' ); continue 2; } } $start = self::utc( (string) $appointment['start_date'], $zone ); $end = self::utc( (string) $appointment['end_date'], $zone ); if ( $start === null || $end === null || $end <= $start ) { $skip( 'appointment', $label, 'appointment_clock_change' ); continue; } $start_sql = $start->format( 'Y-m-d H:i:s' ); $end_sql = $end->format( 'Y-m-d H:i:s' ); $historical = $end_sql <= $stamp; if ( ! $historical && $start_sql < $stamp ) { $skip( 'appointment', $label, 'appointment_ongoing' ); continue; } if ( ! $historical && ! in_array( $status, array( 'pending', 'confirmed' ), true ) ) { $skip( 'appointment', $label, 'appointment_future_status' ); continue; } $minutes = intdiv( $end->getTimestamp() - $start->getTimestamp(), 60 ) + intdiv( max( 0, (int) ( $appointment['extras_duration'] ?? 0 ) ), 60 ); if ( $minutes < 1 || $minutes > 1440 ) { $skip( 'appointment', $label, 'appointment_duration' ); continue; } $notes = array(); if ( (int) ( $row['number_of_persons'] ?? 1 ) > 1 ) { $notes[] = 'party'; } if ( ! empty( $row['payment_id'] ) ) { $notes[] = 'payment'; } if ( ! empty( $row['package_id'] ) || ! empty( $row['series_id'] ) ) { $notes[] = 'series'; } foreach ( $notes as $note ) { $skip( 'appointment_field', $label, 'appointment_' . $note . '_dropped' ); } $out[] = array( 'row' => array( 'source_key' => 'a' . $id, 'display_name' => $customer['display_name'], 'email' => $customer['email'], 'phone' => $customer['phone'], 'service_key' => 's' . self::id( $appointment['service_id'] ), 'staff_key' => 'st' . self::id( $appointment['staff_id'] ), 'location_key' => 'default', 'start_utc' => $start_sql, 'duration_minutes' => $minutes, 'price_cents' => $service['row']['price_cents'], 'currency' => $currency, 'timezone' => $timezone, 'status_slug' => $status ), 'staff' => self::id( $appointment['staff_id'] ), 'historical' => $historical, 'label' => $label ); } $carried = array(); foreach ( self::UNCARRIED as $name ) { if ( isset( $tables[ $name ] ) && count( $tables[ $name ]['values'] ) > 0 ) { $carried[ $name ] = count( $tables[ $name ]['values'] ); } } ksort( $other ); return array( 'version' => $version, 'addons' => $addons, 'addon_tables' => $other, 'uncarried' => $carried, 'source_currency' => $source_currency, 'staff' => $staff, 'customers' => array_values( $customers ), 'services' => $services, 'appointments' => $out, 'skipped' => $skipped ); } /** Rows as field => value maps; malformed tables are refused, never guessed. */ private static function rows( array $table, array $required ): array { $fields = $table['fields']; if ( array_diff( $required, $fields ) || count( $fields ) !== count( array_unique( $fields ) ) ) { throw new \DomainException( 'import_source_format' ); } $out = array(); foreach ( $table['values'] as $values ) { if ( ! is_array( $values ) || count( $values ) !== count( $fields ) ) { throw new \DomainException( 'import_source_format' ); } foreach ( $values as $value ) { if ( $value !== null && ! is_scalar( $value ) ) { throw new \DomainException( 'import_source_format' ); } } $out[] = array_combine( $fields, $values ); } return $out; } private static function id( $value ): int { return ( is_int( $value ) || ( is_string( $value ) && preg_match( '/^[1-9][0-9]{0,9}$/D', $value ) ) ) && (int) $value <= 2147483647 ? (int) $value : 0; } private static function text( $value, int $max ): string { if ( ! is_string( $value ) && ! is_int( $value ) ) { return ''; } $value = trim( preg_replace( '/\s+/u', ' ', (string) $value ) ?? '' ); return mb_substr( $value, 0, $max ); } /** Same cell rules the CSV engine enforces, so one row cannot reject a whole batch. */ private static function cell_safe( string $value ): bool { return strlen( $value ) <= Csv::CELL && ! preg_match( '/[\x00-\x08\x0b\x0c\x0e-\x1f]/', $value ) && ! preg_match( '/^\s*[=@+]/u', $value ); } private static function cents( $value ): int { $value = is_string( $value ) || is_int( $value ) || is_float( $value ) ? (string) $value : '0'; if ( ! preg_match( '/^([0-9]{1,9})(?:\.([0-9]{1,6}))?$/D', $value, $match ) ) { return 0; } return (int) $match[1] * 100 + (int) round( (float) ( '0.' . ( $match[2] ?? '0' ) ) * 100 ); } /** Wall time to UTC; null inside a clock change, where the wall time is missing or ambiguous. */ private static function utc( string $local, \DateTimeZone $zone ): ?\DateTimeImmutable { $naive = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $local, new \DateTimeZone( 'UTC' ) ); if ( ! $naive || $naive->format( 'Y-m-d H:i:s' ) !== $local ) { return null; } $transitions = $zone->getTransitions( $naive->getTimestamp() - 2 * DAY_IN_SECONDS, $naive->getTimestamp() + 2 * DAY_IN_SECONDS ); $matches = 0; $found = null; foreach ( array_unique( array_column( is_array( $transitions ) ? $transitions : array(), 'offset' ) ) as $offset ) { $candidate = $naive->modify( ( $offset >= 0 ? '-' : '+' ) . abs( (int) $offset ) . ' seconds' ); if ( $candidate->setTimezone( $zone )->format( 'Y-m-d H:i:s' ) === $local ) { ++$matches; $found = $candidate; } } return $matches === 1 ? $found : null; } /** Canonical import rows for one staged chunk, verified against the shared engine rules. */ public static function canonical( string $kind, array $rows ): array { $fields = Csv::fields( $kind ); $out = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) || array_keys( $row ) !== $fields ) { throw new \DomainException( 'import_source_format' ); } Service::normalize( $kind, array_map( 'strval', $row ) ); $out[] = array_map( 'strval', $row ); } return $out; } }