service = $service; $this->repository = $repository; } public static function allowed(): bool { return Service::permitted( 'customers' ) && Service::permitted( 'services' ) && Service::permitted( 'appointments' ); } public static function operations(): array { return array( 'bookly_analyze', 'bookly_start', 'bookly_continue', 'bookly_discard', 'bookly_report', 'bookly_undo' ); } /** Handles one Bookly operation; the caller verified the nonce and POST method. */ public function request( array $input, array $file ): array { if ( ! self::allowed() || ! $this->service->supports_appointments() ) { return array( 'error' => 'import_permission' ); } try { $operation = $input['operation'] ?? ''; if ( $operation === 'bookly_analyze' ) { return $this->analyze( $input, $file ); } $id = $input['run'] ?? ''; if ( ! BooklyRun::valid_id( $id ) ) { throw new \DomainException( 'import_token' ); } $run = BooklyRun::get( $id ); if ( $operation === 'bookly_discard' ) { BooklyRun::discard( $id ); return array( 'stage' => 'upload' ); } if ( $operation === 'bookly_report' ) { return array( 'stage' => 'bookly', 'run' => $id, 'notice' => $this->report( $run, ( $input['consent'] ?? '' ) === '1' ) ); } if ( $operation === 'bookly_undo' ) { if ( $run['state'] !== 'done' ) { throw new \DomainException( 'import_replay' ); } $this->undo( $id, $run ); return array( 'stage' => 'bookly', 'run' => $id ); } if ( $operation === 'bookly_start' ) { if ( $run['state'] !== 'mapping' ) { throw new \DomainException( 'import_replay' ); } $run['mapping'] = $this->mapping( $run, $input ); $run['state'] = 'running'; BooklyRun::save( $id, $run ); } if ( $run['state'] === 'running' ) { $this->work( $id ); } return array( 'stage' => 'bookly', 'run' => $id ); } catch ( \DomainException $error ) { return array( 'error' => $error->getMessage() ); } catch ( \Throwable $error ) { return array( 'error' => 'import_storage' ); } } private function analyze( array $input, array $file ): array { $timezone = $input['timezone'] ?? ''; if ( ! is_string( $timezone ) || ( $timezone !== 'UTC' && ! in_array( $timezone, \DateTimeZone::listIdentifiers(), true ) ) ) { throw new \DomainException( 'import_timezone' ); } if ( ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) !== UPLOAD_ERR_OK || ! is_string( $file['name'] ?? null ) || strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ) !== 'json' || ( $file['size'] ?? 0 ) > Bookly::BYTES || ! is_string( $file['tmp_name'] ?? null ) || ! is_uploaded_file( $file['tmp_name'] ) ) { throw new \DomainException( 'import_bookly_upload' ); } try { wp_raise_memory_limit( 'admin' ); $bytes = file_get_contents( $file['tmp_name'], false, null, 0, Bookly::BYTES + 1 ); if ( ! is_string( $bytes ) ) { throw new \RuntimeException( 'import_upload' ); } $currency = (string) ( \Abientot\Admin\Settings::values()['currency'] ?? 'USD' ); $analysis = Bookly::analyze( $bytes, $timezone, $currency ); } finally { if ( is_uploaded_file( $file['tmp_name'] ) ) { wp_delete_file( $file['tmp_name'] ); } } unset( $bytes ); return array( 'stage' => 'bookly', 'run' => BooklyRun::create( $analysis, $timezone ) ); } /** Validated staff/location choice. Free imports one Bookly staff member onto its one staff record. */ private function mapping( array $run, array $input ): array { $staff = is_array( $input['staff_map'] ?? null ) ? $input['staff_map'] : array(); if ( ! Service::pro_enabled() ) { $default = ( new \Abientot\Repositories\StaffRepository() )->default_staff(); $staff = array( (string) ( $input['free_staff'] ?? '' ) => (int) ( $default['id'] ?? 0 ) ); } $location = (int) ( $input['location'] ?? 0 ); $out = array( 'staff' => array(), 'location' => 0 ); foreach ( array_keys( $run['staff'] ) as $bookly ) { $target = (int) ( $staff[ $bookly ] ?? 0 ); if ( $target > 0 ) { $record = $this->repository->find( 'staff', $target ); if ( ! $record || $record['status'] !== 'active' ) { throw new \DomainException( 'import_reference' ); } Service::destination( 'staff', $target ); } $out['staff'][ (string) $bookly ] = max( 0, $target ); } if ( ! Service::pro_enabled() && count( array_filter( $out['staff'] ) ) > 1 ) { throw new \DomainException( 'import_bookly_free_staff' ); } if ( ! array_filter( $out['staff'] ) ) { throw new \DomainException( 'import_reference' ); } $record = $this->repository->find( 'locations', $location ); if ( ! $record || $record['status'] !== 'active' ) { throw new \DomainException( 'import_reference' ); } Service::destination( 'locations', $location ); $out['location'] = $location; return $out; } /** Runs pending chunks within a time budget; the page offers Continue for the rest. */ private function work( string $id ): void { $started = time(); $run = BooklyRun::get( $id ); foreach ( $run['chunks'] as $index => $chunk ) { if ( $chunk['state'] !== 'pending' ) { continue; } if ( time() - $started >= self::BUDGET ) { return; } $this->chunk( $id, $run, (int) $chunk['n'], $chunk['kind'] ); $run['chunks'][ $index ]['state'] = 'done'; BooklyRun::drop_chunk( $id, (int) $chunk['n'] ); BooklyRun::save( $id, $run ); } $run['state'] = 'done'; BooklyRun::save( $id, $run ); } private function chunk( string $id, array &$run, int $n, string $kind ): void { $staged = BooklyRun::chunk( $id, $n ); $items = array(); $mapping = $run['mapping']; foreach ( $staged['rows'] as $item ) { $row = $item['row'] ?? $item; $label = $item['label'] ?? ( $row['display_name'] ?? $row['name'] ?? '' ); $prior = $this->earlier( $run['namespace'], $kind, $row['source_key'] ); if ( $prior !== null ) { ++$run['imported']['earlier']; if ( $kind === 'services' && isset( $prior['service_id'] ) ) { $run['service_ids'][ $row['source_key'] ] = (int) $prior['service_id']; } continue; } if ( $kind === 'appointments' ) { if ( empty( $mapping['staff'][ (string) $item['staff'] ] ) ) { BooklyRun::skip( $run, 'appointment', $label, Service::pro_enabled() ? 'appointment_staff_unmapped' : 'appointment_staff_pro' ); continue; } if ( empty( $run['service_ids'][ $row['service_key'] ] ) ) { BooklyRun::skip( $run, 'appointment', $label, 'appointment_service' ); continue; } } $items[] = $item; } if ( $kind === 'services' ) { $groups = array(); foreach ( $items as $item ) { $groups[ $this->service_staff( $run, $item['row']['source_key'] ) ][] = $item; } foreach ( $groups as $staff_id => $group ) { foreach ( $this->batch( $id, $run, $n, $kind, $group, array( 'staff_id' => $staff_id ) ) as $pair ) { $run['service_ids'][ $pair[0]['row']['source_key'] ] = (int) $pair[1]['service_id']; ++$run['imported']['services']; } } $this->links( $run ); return; } $settings = array(); if ( $kind === 'appointments' ) { $settings = array( 'time_format' => 'utc_sql', 'references' => array( 'services' => array(), 'staff' => array(), 'locations' => array( 'default' => $mapping['location'] ) ) ); foreach ( $items as $item ) { $settings['references']['services'][ $item['row']['service_key'] ] = $run['service_ids'][ $item['row']['service_key'] ]; $settings['references']['staff'][ $item['row']['staff_key'] ] = $mapping['staff'][ (string) $item['staff'] ]; } } $done = $this->batch( $id, $run, $n, $kind, $items, $settings ); $run['imported'][ $kind === 'customers' ? 'customers' : 'appointments' ] += count( $done ); } /** Preview, set aside refused rows with their reason, re-preview, then commit atomically. */ private function batch( string $id, array &$run, int $n, string $kind, array $items, array $extra ): array { for ( $attempt = 0; $attempt < 4 && $items; ++$attempt ) { $items = array_values( $items ); BooklyRun::save_chunk( $id, $n, array( 'kind' => $kind, 'rows' => $items ) ); if ( $kind === 'appointments' ) { $extra['references']['services'] = array_intersect_key( $extra['references']['services'], array_flip( array_map( static fn( $item ) => $item['row']['service_key'], $items ) ) ); $extra['references']['staff'] = array_intersect_key( $extra['references']['staff'], array_flip( array_map( static fn( $item ) => $item['row']['staff_key'], $items ) ) ); } $reference = BooklyRun::reference( $id, $n, $kind ); $csv = BooklyRun::normalize( 'bookly_' . $kind . '_v1', $reference )['csv']; $preview = $this->service->preview( $csv, ',', $kind, Csv::fields( $kind ), array_merge( array( 'duplicate_policy' => 'reuse', 'source_namespace' => $run['namespace'], 'source_format' => 'bookly_' . $kind . '_v1', 'source_bytes' => $reference, 'source_delimiter' => ',' ), $extra ) ); if ( $preview['errors'] ) { foreach ( $preview['errors'] as $error ) { $index = (int) $error['row'] - 2; if ( isset( $items[ $index ] ) ) { $row = $items[ $index ]['row'] ?? $items[ $index ]; BooklyRun::skip( $run, $kind === 'customers' ? 'customer' : rtrim( $kind, 's' ), $items[ $index ]['label'] ?? ( $row['display_name'] ?? '' ), 'engine:' . $error['code'] ); unset( $items[ $index ] ); } } continue; } $result = $this->service->commit( $preview['token'] ); BooklyRun::remember_batch( $result['batch_uuid'] ); $run['batches'][] = array( 'uuid' => $result['batch_uuid'], 'kind' => $kind, 'count' => count( $result['rows'] ) ); return array_map( null, $items, $result['rows'] ); } foreach ( $items as $item ) { BooklyRun::skip( $run, rtrim( $kind, 's' ), $item['label'] ?? '', 'engine:import_stale' ); } return array(); } /** Result of an earlier run for the same Bookly record, so a re-run only adds what is missing. */ private function earlier( string $namespace, string $kind, string $source_key ): ?array { $hash = Token::digest( array( 'namespace' => $namespace, 'kind' => $kind, 'source_key' => $source_key ) ); $row = $this->repository->source( $hash ); if ( ! $row || $row['state'] !== 'committed' ) { return null; } foreach ( $this->repository->batch_rows( $row['batch_uuid'] ) as $line ) { if ( hash_equals( $line['source_hash'], $hash ) ) { $owned = json_decode( $line['ownership_json'], true, 16 ); return is_array( $owned['result'] ?? null ) ? $owned['result'] : array(); } } return array(); } private function service_staff( array $run, string $source_key ): int { foreach ( $run['service_staff'][ (int) substr( $source_key, 1 ) ] ?? array() as $bookly ) { if ( ! empty( $run['mapping']['staff'][ (string) $bookly ] ) ) { return (int) $run['mapping']['staff'][ (string) $bookly ]; } } return (int) current( array_filter( $run['mapping']['staff'] ) ); } /** Offer each service with every mapped staff member who offered it in Bookly; remember added links for undo. */ private function links( array &$run ): void { global $wpdb; $services = new \Abientot\Repositories\ServicesRepository(); $table = $this->repository->table( 'staff_services' ); foreach ( $run['service_ids'] as $key => $service_id ) { $targets = array( $this->service_staff( $run, $key ) ); foreach ( $run['service_staff'][ (int) substr( $key, 1 ) ] ?? array() as $bookly ) { $targets[] = (int) ( $run['mapping']['staff'][ (string) $bookly ] ?? 0 ); } foreach ( array_unique( array_filter( $targets ) ) as $staff_id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh existence check before a link write; must not be cached. if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE service_id=%d AND staff_id=%d', $table, (int) $service_id, $staff_id ) ) ) { continue; } $services->link_to_staff( (int) $service_id, $staff_id ); $run['links'][] = array( (int) $service_id, $staff_id ); } } } /** Undo the whole run, newest data first; links this run added go before their services. */ private function undo( string $id, array &$run ): void { global $wpdb; $run['blocked'] = array(); $order = array( 'appointments', 'links', 'services', 'customers' ); foreach ( $order as $kind ) { if ( $kind === 'links' ) { // A blocked appointment still needs its staff link; a later undo removes it. if ( $run['blocked'] ) { continue; } foreach ( $run['links'] ?? array() as $link ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Removes exactly the links this run added; plugin-owned table. $wpdb->delete( $this->repository->table( 'staff_services' ), array( 'service_id' => (int) $link[0], 'staff_id' => (int) $link[1] ), array( '%d', '%d' ) ); } $run['links'] = array(); continue; } foreach ( array_reverse( $run['batches'], true ) as $index => $batch ) { if ( $batch['kind'] !== $kind || ! empty( $batch['undone'] ) ) { continue; } $assessment = $this->service->rollback_preview( $batch['uuid'] ); if ( $assessment['token'] === '' ) { foreach ( $assessment['blocked'] as $record ) { $run['blocked'][] = $record['kind'] . ' #' . $record['id']; } continue; } $this->service->rollback( $assessment['token'] ); $run['batches'][ $index ]['undone'] = true; } } $run['state'] = $run['blocked'] ? 'done' : 'undone'; if ( ! $run['blocked'] ) { BooklyRun::release( $this->repository ); } BooklyRun::save( $id, $run ); } /** Values-free description of what this importer cannot carry yet; nothing from customer records. */ public static function report_body( array $run ): string { $lines = array( 'Abientot Bookly add-on report', 'Bookly: ' . $run['version'] ); foreach ( $run['addons'] as $slug => $version ) { $lines[] = 'Add-on: ' . $slug . ' ' . $version; } foreach ( $run['addon_tables'] as $class => $table ) { $lines[] = 'Table: ' . $class . ' rows=' . (int) $table['rows'] . ' fields=' . implode( ',', $table['fields'] ); } foreach ( $run['uncarried'] as $name => $count ) { $lines[] = 'Not imported: ' . $name . ' rows=' . (int) $count; } foreach ( $run['reasons'] as $reason => $count ) { $lines[] = 'Left out: ' . $reason . ' x' . (int) $count; } $lines[] = 'Abientot: ' . ABIENTOT_VERSION . ( Service::pro_enabled() ? ' Pro' : ' Free' ); return implode( "\n", $lines ) . "\n"; } private function report( array $run, bool $consent ): string { if ( ! current_user_can( 'abientot_manage_settings' ) || ! $consent ) { return __( 'Permission, session or explicit consent is missing.', 'abientot' ); } $last = (int) get_option( 'abientot_bookly_report_sent', 0 ); if ( $last > 0 && time() - $last < 300 ) { return __( 'A report was recently attempted or sending is unavailable. Wait at least five minutes; do not retry an uncertain send.', 'abientot' ); } update_option( 'abientot_bookly_report_sent', time(), false ); $accepted = wp_mail( 'contact@abientot.app', 'Bookly add-on report', self::report_body( $run ), array( 'Content-Type: text/plain; charset=UTF-8' ) ); return $accepted ? __( 'Your mail transport accepted the report. Inbox delivery is not confirmed.', 'abientot' ) : __( 'Your mail transport did not accept the report. No automatic retry will occur.', 'abientot' ); } public static function reasons(): array { return array( 'customer_name' => __( 'No customer name.', 'abientot' ), 'customer_contact' => __( 'No valid email address or phone number.', 'abientot' ), 'customer_cell' => __( 'The name starts with a character that spreadsheets treat as a formula.', 'abientot' ), 'customer_email_dropped' => __( 'Invalid email address left out; imported with the phone number.', 'abientot' ), 'customer_phone_dropped' => __( 'Invalid phone number left out; imported with the email address.', 'abientot' ), 'service_type' => __( 'Compound, collaborative and package services are not supported.', 'abientot' ), 'service_value' => __( 'Missing name, or a duration that is not whole minutes between 1 minute and 24 hours.', 'abientot' ), 'appointment_missing' => __( 'The appointment record is missing from the export.', 'abientot' ), 'appointment_status' => __( 'Unknown status.', 'abientot' ), 'appointment_waitlisted' => __( 'Waiting list entry.', 'abientot' ), 'appointment_customer' => __( 'Its customer could not be imported.', 'abientot' ), 'appointment_service' => __( 'Its service could not be imported.', 'abientot' ), 'appointment_custom_service' => __( 'Custom service without a service record.', 'abientot' ), 'appointment_linked_service' => __( 'Part of a compound or collaborative booking.', 'abientot' ), 'appointment_clock_change' => __( 'The time falls in a daylight saving clock change. Add it by hand.', 'abientot' ), 'appointment_ongoing' => __( 'In progress now. Import the file again after it ends.', 'abientot' ), 'appointment_future_status' => __( 'Future appointment that was cancelled or rejected.', 'abientot' ), 'appointment_duration' => __( 'Duration out of range.', 'abientot' ), 'appointment_party_dropped' => __( 'Booking for several people imported as one booking.', 'abientot' ), 'appointment_payment_dropped' => __( 'Payment not imported; the appointment is marked unpaid.', 'abientot' ), 'appointment_series_dropped' => __( 'Package or recurring link not imported; imported as a single visit.', 'abientot' ), 'appointment_staff_unmapped' => __( 'Its staff member was not selected for import.', 'abientot' ), 'appointment_staff_pro' => __( 'Another staff member: importing several staff members needs Abientot Pro.', 'abientot' ) ); } private function reason( string $code ): string { if ( str_starts_with( $code, 'engine:' ) ) { return ( new Module( $this->service ) )->message( substr( $code, 7 ) ); } return self::reasons()[ $code ] ?? $code; } private static function form( string $operation, bool $upload = false ): void { echo '
'; } public function render( array $result ): void { $id = $result['run']; $run = BooklyRun::get( $id ); if ( ! empty( $result['notice'] ) ) { echo '' . esc_html( $result['notice'] ) . '
'; } if ( $run['state'] === 'mapping' ) { $this->render_mapping( $id, $run ); } elseif ( $run['state'] === 'running' ) { $done = count( array_filter( $run['chunks'], static fn( $chunk ) => $chunk['state'] === 'done' ) ); echo '' . esc_html( sprintf( /* translators: 1: finished steps, 2: all steps. */ __( '%1$d of %2$d steps done.', 'abientot' ), $done, count( $run['chunks'] ) ) ) . '
'; self::form( 'bookly_continue' ); echo ''; submit_button( __( 'Continue', 'abientot' ) ); echo ''; } else { $this->render_done( $id, $run ); } } private function counts( array $run, bool $planned ): void { $numbers = $planned ? $run['planned'] : $run['imported']; $labels = array( /* translators: %d: number of customers. */ 'customers' => __( 'Customers: %d', 'abientot' ), /* translators: %d: number of services. */ 'services' => __( 'Services: %d', 'abientot' ), /* translators: %d: number of past appointments. */ 'past' => __( 'Past appointments: %d', 'abientot' ), /* translators: %d: number of future appointments. */ 'future' => __( 'Future appointments: %d', 'abientot' ), /* translators: %d: number of appointments. */ 'appointments' => __( 'Appointments: %d', 'abientot' ), /* translators: %d: number of records imported by an earlier run. */ 'earlier' => __( 'Already imported earlier: %d', 'abientot' ) ); echo '| ' . esc_html__( 'Record', 'abientot' ) . ' | ' . esc_html__( 'Details', 'abientot' ) . ' |
|---|---|
| ' . esc_html( $item['label'] ) . ' | ' . esc_html( $this->reason( $item['reason'] ) ) . ' |
' . esc_html( sprintf( /* translators: %d: number of rows. */ __( 'Only the first %d rows are listed.', 'abientot' ), count( $run['skipped'] ) ) ) . '
'; } } private function render_addons( array $run ): void { if ( $run['addons'] ) { echo '' . esc_html( sprintf( /* translators: %s: comma-separated Bookly add-on names. */ __( 'Bookly add-ons found: %s. Their data is not imported yet.', 'abientot' ), implode( ', ', array_keys( $run['addons'] ) ) ) ) . '
'; } if ( $run['source_currency'] !== '' && $run['source_currency'] !== (string) ( \Abientot\Admin\Settings::values()['currency'] ?? '' ) ) { echo '' . esc_html( sprintf( /* translators: 1: Bookly currency code, 2: Abientot currency code. */ __( 'Bookly prices were in %1$s and this site uses %2$s. Prices are copied as numbers; check them after the import.', 'abientot' ), $run['source_currency'], (string) \Abientot\Admin\Settings::values()['currency'] ) ) . '
'; } } private static function staff_label( string $name, int $count ): string { /* translators: 1: Bookly staff name, 2: number of appointments. */ return sprintf( _n( '%1$s (%2$d appointment)', '%1$s (%2$d appointments)', $count, 'abientot' ), $name, $count ); } private function render_mapping( string $id, array $run ): void { echo '' . esc_html( sprintf( /* translators: %s: Bookly version. */ __( 'Bookly version %s.', 'abientot' ), $run['version'] ) ) . '
'; $this->counts( $run, true ); $this->render_addons( $run ); $this->render_skipped( $run ); self::form( 'bookly_start' ); echo '' . esc_html__( 'Abientot Free has one staff member. Choose whose Bookly appointments to import; the others need Abientot Pro.', 'abientot' ) . '
'; $first = true; foreach ( $run['staff'] as $bookly => $name ) { echo ''; $first = false; } } $locations = array_filter( $this->repository->choices( 'locations' ), static fn( $row ) => $row['status'] === 'active' && ( Service::pro_enabled() || ! empty( $row['is_default'] ) ) ); echo '' . esc_html__( 'Before importing, set your opening hours, booking window and staff so that future appointments fit them. Future appointments that do not fit are left out and listed.', 'abientot' ) . '
' . esc_html__( 'Imported appointments are marked unpaid. No emails or reminders are sent for them.', 'abientot' ) . '
'; submit_button( __( 'Import', 'abientot' ), 'primary', 'submit', false ); echo ''; self::form( 'bookly_discard' ); echo ''; submit_button( __( 'Discard', 'abientot' ), 'secondary', 'submit', false ); echo ''; } private function render_done( string $id, array $run ): void { echo '' . esc_html__( 'The import was undone. Records that existed before were kept.', 'abientot' ) . '
'; } elseif ( array_filter( $run['batches'], static fn( $batch ) => empty( $batch['undone'] ) ) ) { echo '' . esc_html__( 'Undo removes what this import created, if it has not changed since. Records that existed before are kept.', 'abientot' ) . '
'; if ( ! empty( $run['blocked'] ) ) { echo '' . esc_html( ( new Module( $this->service ) )->message( 'import_rollback_unsafe' ) ) . ' ' . esc_html( implode( ', ', $run['blocked'] ) ) . '
'; } self::form( 'bookly_undo' ); echo ''; submit_button( __( 'Undo this import', 'abientot' ), 'delete', 'submit', false ); echo ''; } if ( $run['addons'] || $run['addon_tables'] || $run['uncarried'] ) { echo '' . esc_html__( 'Send this report so we can add what was not imported. It lists Bookly add-ons, table and field names and counts only, without any customer data.', 'abientot' ) . '
' . esc_html( self::report_body( $run ) ) . ''; self::form( 'bookly_report' ); echo ''; submit_button( __( 'Send report', 'abientot' ), 'secondary', 'submit', false ); echo ''; } self::form( 'bookly_discard' ); echo ''; submit_button( __( 'Close this summary', 'abientot' ), 'secondary', 'submit', false ); echo ''; } }