repository = new Repository(); require_once __DIR__ . '/AppointmentAdapter.php'; $adapter = apply_filters( 'abientot_import_appointment_adapter', ( new AppointmentAdapter( $this->repository ) )->callbacks() ); $this->service = $service ?? new Service( $this->repository, is_array( $adapter ) ? $adapter : null ); } public function register(): void { static $registered = false; if ( $registered ) { return; } $registered = true; add_action( 'admin_menu', array( $this, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'abientot_data_tools_import_panel', array( $this, 'panel' ) ); } public function enqueue( string $hook ): void { if ( self::any() && in_array( $hook, array( 'admin_page_abientot-csv-import', 'abientot_page_abientot-csv-import' ), true ) ) { wp_enqueue_style( 'abientot-import', ABIENTOT_PLUGIN_URL . 'assets/admin/import.css', array(), ABIENTOT_VERSION ); } } private static function any(): bool { return current_user_can( 'abientot_manage_customers' ) || current_user_can( 'abientot_manage_services' ); } public function menu(): void { if ( \Abientot\Support\Request::page() === 'abientot-csv-import' ) { global $title; $title = __( 'Import data', 'abientot' ); } $capability = current_user_can( 'abientot_manage_customers' ) ? 'abientot_manage_customers' : 'abientot_manage_services'; add_submenu_page( '', __( 'Import data', 'abientot' ), __( 'Import data', 'abientot' ), $capability, 'abientot-csv-import', array( $this, 'page' ) ); } public function panel(): void { if ( ! self::any() ) { return; } echo '

' . esc_html__( 'Import data', 'abientot' ) . '

' . esc_html__( 'Import customer contacts from a supported booking plugin.', 'abientot' ) . '

' . esc_html__( 'Open CSV import', 'abientot' ) . '
'; } /** * Runs one import step. The caller has verified the nonce and the POST method; * $input and $file hold sanitized request data only. */ public function request( array $input, array $file ): array { if ( ! self::any() ) { return array( 'error' => 'import_permission' ); } try { $operation = $input['operation'] ?? ''; if ( $operation === 'initialize' ) { if ( ! current_user_can( 'abientot_manage_settings' ) || ! Migrator::ensure() ) { throw new \RuntimeException( 'import_storage' ); } return array( 'stage' => 'upload', 'initialized' => true ); } if ( $operation === 'upload' ) { $format = $input['source_format'] ?? ''; if ( ! is_string( $format ) ) { throw new \DomainException( 'import_permission' ); } $definition = $format !== '' ? Service::source_format( $format ) : null; if ( $definition === null && ! Service::pro_enabled() ) { throw new \DomainException( 'import_permission' ); } $kind = $definition['kind'] ?? ( $input['kind'] ?? '' ); if ( ! is_string( $kind ) || ! Service::permitted( $kind ) || ( $kind === 'appointments' && ! $this->service->supports_appointments() ) ) { throw new \DomainException( 'import_permission' ); } $delimiter = $input['delimiter'] ?? ','; if ( ! is_string( $delimiter ) || ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) !== UPLOAD_ERR_OK || ! is_string( $file['name'] ?? null ) || strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ) !== ( $definition['extension'] ?? 'csv' ) || ( $file['size'] ?? 0 ) > Csv::BYTES || ! is_string( $file['tmp_name'] ?? null ) || ! is_uploaded_file( $file['tmp_name'] ) ) { throw new \DomainException( 'import_upload' ); } try { $csv = file_get_contents( $file['tmp_name'], false, null, 0, Csv::BYTES + 1 ); if ( ! is_string( $csv ) ) { throw new \RuntimeException( 'import_upload' ); } $source = array(); if ( $definition !== null ) { $source = array( 'source_format' => $format, 'source_bytes' => $csv, 'source_delimiter' => $delimiter ); $normalized = Sources::normalize( $format, $csv, $delimiter ); $csv = $normalized['csv']; $delimiter = ','; } $parsed = Csv::parse( $csv, $delimiter ); } finally { if ( is_uploaded_file( $file['tmp_name'] ) ) { wp_delete_file( $file['tmp_name'] ); } } return array( 'stage' => 'mapping', 'kind' => $kind, 'parsed' => $parsed, 'automatic' => $definition !== null, 'token' => Token::seal( 'upload', array( 'csv' => $csv, 'delimiter' => $delimiter, 'kind' => $kind, 'source' => $source ), get_current_user_id() ) ); } if ( $operation === 'preview' || $operation === 'references' ) { if ( ! is_string( $input['token'] ?? null ) ) { throw new \DomainException( 'import_token' ); } $upload = Token::open( $input['token'], 'upload', get_current_user_id() ); $kind = $upload['kind']; if ( ! Service::permitted( $kind ) ) { throw new \DomainException( 'import_permission' ); } $mapping = $input['mapping'] ?? null; $settings = $input['settings'] ?? null; if ( ! is_array( $mapping ) || ! is_array( $settings ) ) { throw new \DomainException( 'import_mapping' ); } if ( array_intersect( array_keys( $settings ), array( 'source_format', 'source_bytes', 'source_delimiter' ) ) ) { throw new \DomainException( 'import_settings' ); } $settings = array_merge( $settings, $upload['source'] ?? array() ); if ( $kind === 'appointments' && $operation === 'references' ) { $parsed = Csv::parse( $upload['csv'], $upload['delimiter'] ); $raw = Csv::map( $parsed, $kind, $mapping ); $references = array(); foreach ( array( 'services' => 'service_key', 'staff' => 'staff_key', 'locations' => 'location_key' ) as $type => $field ) { $references[ $type ] = array_values( array_unique( array_column( $raw, $field ) ) ); } return array( 'stage' => 'references', 'kind' => $kind, 'mapping' => $mapping, 'settings' => array_diff_key( $settings, array_flip( array( 'source_format', 'source_bytes', 'source_delimiter' ) ) ), 'references' => $references, 'token' => $input['token'] ); } if ( $kind === 'appointments' ) { $parsed = Csv::parse( $upload['csv'], $upload['delimiter'] ); $raw = Csv::map( $parsed, $kind, $mapping ); $ids = $settings['reference_ids'] ?? null; if ( ! is_array( $ids ) || array_diff( array_keys( $ids ), array( 'services', 'staff', 'locations' ) ) ) { throw new \DomainException( 'import_reference' ); } $settings['references'] = array(); foreach ( array( 'services' => 'service_key', 'staff' => 'staff_key', 'locations' => 'location_key' ) as $type => $field ) { $keys = array_values( array_unique( array_column( $raw, $field ) ) ); if ( ! is_array( $ids[ $type ] ?? null ) || count( $ids[ $type ] ) !== count( $keys ) ) { throw new \DomainException( 'import_reference' ); } foreach ( $keys as $index => $key ) { $settings['references'][ $type ][ (string) $key ] = $ids[ $type ][ $index ] ?? null; } } unset( $settings['reference_ids'] ); } return array( 'stage' => 'preview', 'preview' => $this->service->preview( $upload['csv'], $upload['delimiter'], $kind, $mapping, $settings ) ); } if ( $operation === 'commit' ) { if ( ! is_string( $input['token'] ?? null ) ) { throw new \DomainException( 'import_token' ); } return array( 'stage' => 'result', 'result' => $this->service->commit( $input['token'] ) ); } if ( $operation === 'rollback_preview' ) { if ( ! is_string( $input['batch_uuid'] ?? null ) || ! preg_match( '/^[a-f0-9-]{36}$/D', $input['batch_uuid'] ) ) { throw new \DomainException( 'import_token' ); } return array( 'stage' => 'rollback', 'rollback' => $this->service->rollback_preview( $input['batch_uuid'] ) ); } if ( $operation === 'rollback' ) { if ( ! is_string( $input['token'] ?? null ) ) { throw new \DomainException( 'import_token' ); } return array( 'stage' => 'rolled_back', 'result' => $this->service->rollback( $input['token'] ) ); } throw new \DomainException( 'import_operation' ); } catch ( \DomainException $error ) { return array( 'error' => $error->getMessage() ); } catch ( \Throwable $error ) { return array( 'error' => 'import_storage' ); } } public function message( string $code ): string { $messages = array( 'import_source_format' => __( 'This file does not match the selected export format. Analyze it locally below to request support.', 'abientot' ), 'import_status' => __( 'Choose a supported status. Future appointments cannot be completed or marked as no-show.', 'abientot' ), 'import_ongoing' => __( 'An appointment currently in progress cannot be imported.', 'abientot' ), 'import_appointment_validation' => __( 'This future appointment is unavailable or its price, duration, assignment or required intake needs review.', 'abientot' ), 'import_namespace' => __( 'Choose a stable source namespace, such as the name of the previous system.', 'abientot' ), 'import_previous_source' => __( 'This source key was already imported. Use the original batch result or resolve the source record.', 'abientot' ), 'import_permission' => __( 'You do not have permission to run this import.', 'abientot' ), 'import_method' => __( 'Imports require a POST request.', 'abientot' ), 'import_nonce' => __( 'Your session expired. Reload the page and try again.', 'abientot' ), 'import_token' => __( 'This import preview expired or belongs to another user. Upload the CSV again.', 'abientot' ), 'import_stale' => __( 'The records or configuration changed after preview. Preview the file again; nothing was imported.', 'abientot' ), 'import_replay' => __( 'This import command is no longer available. No records were recreated.', 'abientot' ), 'import_storage' => __( 'Import storage or a checked write failed. No partial import was saved.', 'abientot' ), 'import_rollback_unsafe' => __( 'Some imported records changed or have linked activity. Rollback is unavailable; review the listed records.', 'abientot' ), 'import_ambiguous_contact' => __( 'The contact matches several customers. Resolve the ambiguity before importing.', 'abientot' ), 'import_duplicate' => __( 'A contact or service slug already exists, or appears more than once in this file.', 'abientot' ), 'import_duplicate_source' => __( 'Source keys must be unique within the file.', 'abientot' ), 'import_utc' => __( 'Use explicit UTC timestamps in YYYY-MM-DD HH:MM:SS format. Local wall times are unsupported.', 'abientot' ), 'import_timezone' => __( 'Choose a valid IANA timezone for the appointment snapshot.', 'abientot' ), 'import_reference' => __( 'Map each external service, staff and location key to an active record on this site.', 'abientot' ), 'import_customer' => __( 'A customer needs a name and a valid email address or phone number.', 'abientot' ), 'import_currency' => __( 'Currency must contain three uppercase letters.', 'abientot' ), 'import_integer' => __( 'Amounts, durations and buffers must be bounded whole numbers.', 'abientot' ), 'import_mapping' => __( 'Map every column explicitly or choose Ignore. Required fields need distinct columns.', 'abientot' ), 'import_appointment_adapter' => __( 'Appointment import is unavailable until the canonical import adapter is configured.', 'abientot' ), 'import_upload' => __( 'Choose a supported CSV or JSON export of at most 256 KiB.', 'abientot' ), 'import_rows' => __( 'A CSV file must contain a header and 1–100 data rows.', 'abientot' ), 'import_cell' => __( 'A CSV cell is oversized or contains a formula or invalid control character.', 'abientot' ), 'import_columns' => __( 'Each row must have the same number of columns, with at most 30 columns.', 'abientot' ), 'import_csv' => __( 'The CSV is malformed, oversized or not valid UTF-8.', 'abientot' ), 'import_header' => __( 'CSV headers must be nonempty and unique.', 'abientot' ) ); return $messages[ $code ] ?? __( 'The row contains an invalid value. Correct it and preview the file again.', 'abientot' ); } private function start( string $operation, bool $upload = false ): void { echo '
'; wp_nonce_field( 'abientot_import_csv' ); echo ''; } private function hidden( string $name, $value ): void { if ( is_array( $value ) ) { foreach ( $value as $key => $cell ) { $this->hidden( $name . '[' . $key . ']', $cell ); } } else { echo ''; } } public function page(): void { if ( ! self::any() ) { wp_die( esc_html( $this->message( 'import_permission' ) ) ); } ( new \Abientot\Admin\PageRenderer() )->render_external_module( 'csv-import', array( $this, 'render_page' ), __( 'Import data', 'abientot' ) ); } public function render_page(): void { if ( ! self::any() ) { wp_die( esc_html( $this->message( 'import_permission' ) ) ); } echo '

' . esc_html__( 'Import data', 'abientot' ) . '

' . esc_html__( 'Supported competitor formats import customer contacts only. Other exported data is not imported. Review the contacts before saving; no notifications are sent.', 'abientot' ) . '

'; $submitted = \Abientot\Support\Request::is_post(); $post = array(); if ( $submitted ) { check_admin_referer( 'abientot_import_csv' ); $post = \Abientot\Support\Request::post_fields( 'abientot_import_csv', array( 'batch_uuid', 'delimiter', 'kind', 'mapping', 'operation', 'reference_ids', 'references', 'settings', 'source', 'source_format', 'token' ) ); } if ( $submitted && ( $post['operation'] ?? '' ) === 'initialize' ) { $initialized = $this->request( $post, array() ); if ( isset( $initialized['error'] ) ) { echo '

' . esc_html( $this->message( $initialized['error'] ) ) . '

'; } } if ( get_option( Migrator::OPTION, '' ) !== Migrator::VERSION || ! Migrator::verified() ) { echo '

' . esc_html( $this->message( 'import_storage' ) ) . '

'; if ( current_user_can( 'abientot_manage_settings' ) ) { $this->start( 'initialize' ); submit_button( __( 'Initialize import storage', 'abientot' ) ); echo ''; } do_action( 'abientot_import_compatibility_panel' ); echo '
'; return; } $result = $submitted && ( $post['operation'] ?? '' ) !== 'initialize' ? $this->request( $post, \Abientot\Support\Request::files( 'abientot_import_csv', 'csv_file' ) ) : array( 'stage' => 'upload' ); if ( isset( $result['error'] ) ) { echo '

' . esc_html( $this->message( $result['error'] ) ) . '

'; $result = array( 'stage' => 'upload' ); } $stage = $result['stage']; if ( $stage === 'upload' ) { $this->upload(); } elseif ( $stage === 'mapping' ) { $this->mapping( $result ); } elseif ( $stage === 'references' ) { $this->references( $result ); } elseif ( $stage === 'preview' ) { $this->preview( $result['preview'] ); } elseif ( $stage === 'result' ) { echo '

' . esc_html__( 'Import saved. Reused records were preserved.', 'abientot' ) . '

' . esc_html__( 'Batch reference', 'abientot' ) . ': ' . esc_html( $result['result']['batch_uuid'] ) . '

'; $this->start( 'rollback_preview' ); $this->hidden( 'batch_uuid', $result['result']['batch_uuid'] ); submit_button( __( 'Review batch rollback', 'abientot' ) ); echo ''; } elseif ( $stage === 'rollback' ) { $this->rollback( $result['rollback'] ); } elseif ( $stage === 'rolled_back' ) { echo '

' . esc_html__( 'Owned unchanged records removed. Reused customers and services were preserved.', 'abientot' ) . '

'; } echo '

' . esc_html__( 'Start another import', 'abientot' ) . '

'; } private function upload(): void { $this->start( 'upload', true ); echo '

'; do_action( 'abientot_import_manual_kind' ); echo '

'; submit_button( __( 'Choose column mapping', 'abientot' ) ); echo ''; do_action( 'abientot_import_compatibility_panel' ); } private function labels(): array { return array( 'ignore' => __( 'Ignore this column', 'abientot' ), 'source_key' => __( 'Stable source key', 'abientot' ), 'display_name' => __( 'Customer name', 'abientot' ), 'email' => __( 'Email', 'abientot' ), 'phone' => __( 'Phone', 'abientot' ), 'name' => __( 'Service name', 'abientot' ), 'slug' => __( 'Service slug', 'abientot' ), 'duration_minutes' => __( 'Duration in minutes', 'abientot' ), 'price_cents' => __( 'Price in currency minor units', 'abientot' ), 'currency' => __( 'Currency', 'abientot' ), 'buffer_before_minutes' => __( 'Buffer before in minutes', 'abientot' ), 'buffer_after_minutes' => __( 'Buffer after in minutes', 'abientot' ), 'service_key' => __( 'External service key', 'abientot' ), 'staff_key' => __( 'External staff key', 'abientot' ), 'location_key' => __( 'External location key', 'abientot' ), 'start_utc' => __( 'UTC start', 'abientot' ), 'timezone' => __( 'Timezone snapshot', 'abientot' ), 'status_slug' => __( 'Booking status slug', 'abientot' ) ); } private function mapping( array $result ): void { $kind = $result['kind']; $automatic = ! empty( $result['automatic'] ); $this->start( $kind === 'appointments' ? 'references' : 'preview' ); $this->hidden( 'token', $result['token'] ); echo '

' . esc_html( $automatic ? __( 'Customers', 'abientot' ) : __( 'Column mapping', 'abientot' ) ) . '

'; $labels = $this->labels();if ( $automatic ) { $this->hidden( 'mapping', $result['parsed']['headers'] ); echo ''; foreach ( Csv::map( $result['parsed'], $kind, $result['parsed']['headers'] )as$row ) { echo ''; } } else { echo '

' . esc_html__( 'Nothing is inferred from source numeric IDs. Choose a field for every column; ignored columns will not be imported. Prices use integer minor units, for example 1250 for EUR 12.50.', 'abientot' ) . '

' . esc_html__( 'Customer name', 'abientot' ) . '' . esc_html__( 'Email', 'abientot' ) . '' . esc_html__( 'Phone', 'abientot' ) . '
' . esc_html( $row['display_name'] ) . '' . esc_html( $row['email'] ) . '' . esc_html( $row['phone'] ) . '
'; foreach ( $result['parsed']['headers']as$index => $header ) { echo ''; } } echo '
' . esc_html__( 'CSV column', 'abientot' ) . '' . esc_html__( 'First row sample', 'abientot' ) . '' . esc_html__( 'Target field', 'abientot' ) . '
' . esc_html( $header ) . '' . esc_html( $result['parsed']['rows'][0][ $index ] ) . '

' . esc_html__( 'The reuse policy shares matching customers within this file. Conflicting contacts, repeated service slugs and repeated source keys must be resolved before importing.', 'abientot' ) . '

'; if ( $kind === 'services' ) { $this->choice( 'settings[staff_id]', __( 'Assign new services to staff', 'abientot' ), 'staff' ); } if ( $kind === 'appointments' ) { $this->hidden( 'settings[time_format]', 'utc_sql' ); echo '

' . esc_html( $this->message( 'import_utc' ) ) . '

'; } submit_button( $kind === 'appointments' ? __( 'Map appointment references', 'abientot' ) : __( 'Preview import', 'abientot' ) ); echo ''; } private function choice( string $name, string $label, string $type ): void { echo '

'; } private function references( array $result ): void { $this->start( 'preview' ); $this->hidden( 'token', $result['token'] ); $this->hidden( 'mapping', $result['mapping'] ); $this->hidden( 'settings', $result['settings'] ); echo '

' . esc_html__( 'Appointment reference mapping', 'abientot' ) . '

'; foreach ( $result['references'] as $type => $keys ) { foreach ( $keys as $index => $key ) { $labels = array( 'services' => __( 'Services', 'abientot' ), 'staff' => __( 'Staff', 'abientot' ), 'locations' => __( 'Locations', 'abientot' ) ); $this->choice( 'settings[reference_ids][' . $type . '][' . $index . ']', $labels[ $type ] . ' · ' . $key, $type ); } } submit_button( __( 'Preview import', 'abientot' ) ); echo ''; } private function preview( array $preview ): void { echo '

' . esc_html__( 'Import preview', 'abientot' ) . '

' . esc_html( sprintf( /* translators: %d: number of CSV rows. */__( 'Rows reviewed: %d', 'abientot' ), $preview['count'] ) ) . '

'; foreach ( $preview['errors'] as $error ) { echo '

' . esc_html( sprintf( /* translators: %d: physical CSV row number. */__( 'Row %d', 'abientot' ), $error['row'] ) ) . ' — ' . esc_html( $this->message( $error['code'] ) ) . '

'; } echo ''; foreach ( $preview['rows'] as $row ) { $reuse = (int) ( $row['service_plan']['id'] ?? $row['customer_plan']['id'] ?? 0 ); $details = ''; if ( isset( $row['customer'] ) ) { $details = $row['customer']['email'] . ' · ' . $row['customer']['phone']; } if ( isset( $row['service'] ) ) { $record = $row['service']; $details = $record['slug'] . ' · ' . $record['duration_minutes'] . ' ' . __( 'minutes', 'abientot' ) . ' · ' . $record['currency'] . ' ' . \Abientot\Support\Locale::number( ( $record['price_cents'] ) / 100, \Abientot\Support\Currency::display_precision( (int) ( $record['price_cents'] ), $record['currency'] ) ); } if ( isset( $row['candidate'] ) ) { $record = $row['candidate']; $details .= ' · ' . $record['start_utc'] . ' UTC · ' . $record['timezone'] . ' · ' . $record['duration_minutes'] . ' ' . __( 'minutes', 'abientot' ) . ' · ' . $record['currency'] . ' ' . \Abientot\Support\Locale::number( ( $record['price_cents'] ) / 100, \Abientot\Support\Currency::display_precision( (int) ( $record['price_cents'] ), $record['currency'] ) ) . ' · ' . ( $row['historical'] ? __( 'Historical appointment', 'abientot' ) : __( 'Future appointment', 'abientot' ) ); } $action = isset( $row['candidate'] ) ? __( 'Create appointment', 'abientot' ) : ( $reuse ? __( 'Reuse existing record', 'abientot' ) : __( 'Create record', 'abientot' ) ); $existing = $row['service_plan']['label'] ?? $row['customer_plan']['label'] ?? ''; echo ''; } echo '
' . esc_html__( 'Source key', 'abientot' ) . '' . esc_html__( 'Record', 'abientot' ) . '' . esc_html__( 'Details', 'abientot' ) . '' . esc_html__( 'Action', 'abientot' ) . '
' . esc_html( $row['source_key'] ) . '' . esc_html( $row['service']['name'] ?? $row['customer']['display_name'] ?? '' ) . '' . esc_html( $details ) . '' . esc_html( $action ) . ( $reuse ? ' · ' . esc_html__( 'Preserve existing record', 'abientot' ) . ' #' . esc_html( (string) $reuse ) . ' ' . esc_html( $existing ) : '' ) . '
'; if ( $preview['token'] !== '' ) { $this->start( 'commit' ); $this->hidden( 'token', $preview['token'] ); submit_button( __( 'Confirm atomic import', 'abientot' ) ); echo ''; } } private function rollback( array $assessment ): void { echo '

' . esc_html__( 'Batch rollback review', 'abientot' ) . '

'; if ( $assessment['blocked'] ) { echo '

' . esc_html( $this->message( 'import_rollback_unsafe' ) ) . '

'; } elseif ( $assessment['token'] !== '' ) { echo '

' . esc_html__( 'Rollback deletes only unchanged records created by this batch. Linked activity prevents deletion. Reused records are preserved.', 'abientot' ) . '

'; $this->start( 'rollback' ); $this->hidden( 'token', $assessment['token'] ); submit_button( __( 'Confirm batch rollback', 'abientot' ), 'delete' ); echo ''; } } }