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 '';
}
/**
* 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 '
';
} 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 '' . esc_html__( 'Source format', 'abientot' ) . ' ';
do_action( 'abientot_import_manual_option' );
foreach ( Sources::formats() as $format => $definition ) {
try {
Service::source_format( $format );
} catch ( \DomainException $error ) {
continue;
}
if ( Service::permitted( $definition['kind'] ) ) {
echo '' . esc_html( $definition['label'] ) . ' ';
}
}
echo '
';
do_action( 'abientot_import_manual_kind' );
echo '' . esc_html__( 'Export file', 'abientot' ) . '
' . esc_html__( 'Delimiter', 'abientot' ) . ' ' . esc_html__( 'Comma', 'abientot' ) . ' ' . esc_html__( 'Semicolon', 'abientot' ) . ' ' . esc_html__( 'Tab', 'abientot' ) . '
';
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 '' . esc_html__( 'Customer name', 'abientot' ) . ' ' . esc_html__( 'Email', 'abientot' ) . ' ' . esc_html__( 'Phone', 'abientot' ) . ' ';
foreach ( Csv::map( $result['parsed'], $kind, $result['parsed']['headers'] )as$row ) {
echo '' . esc_html( $row['display_name'] ) . ' ' . esc_html( $row['email'] ) . ' ' . esc_html( $row['phone'] ) . ' ';
}
} 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__( 'CSV column', 'abientot' ) . ' ' . esc_html__( 'First row sample', 'abientot' ) . ' ' . esc_html__( 'Target field', 'abientot' ) . ' ';
foreach ( $result['parsed']['headers']as$index => $header ) {
echo '' . esc_html( $header ) . ' ' . esc_html( $result['parsed']['rows'][0][ $index ] ) . ' ' . esc_html( $labels['ignore'] ) . ' ';
foreach ( Csv::fields( $kind )as$field ) {
echo '' . esc_html( $labels[ $field ] ) . ' ';
}
echo ' ';
}
}
echo '
' . esc_html__( 'Name of the system you are importing from', 'abientot' ) . '
' . esc_html__( 'When a record already exists', 'abientot' ) . ' ' . esc_html__( 'Use the matching record without changing it', 'abientot' ) . ' ' . esc_html__( 'Stop and ask me to resolve duplicates', 'abientot' ) . '
' . 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 '' . esc_html( $label ) . ' ' . esc_html__( 'Choose an active record', 'abientot' ) . ' ';
foreach ( $this->repository->choices( $type ) as $record ) {
if ( $record['status'] === 'active' ) {
if ( ! Service::pro_enabled() && $type !== 'services' ) {
try {
Service::destination( $type, (int) $record['id'] );
} catch ( \DomainException $error ) {
continue;
}
}
echo '' . esc_html( ( $record['name'] ?? $record['display_name'] ) . ' · #' . $record['id'] ) . ' ';
}
}
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 '' . esc_html__( 'Source key', 'abientot' ) . ' ' . esc_html__( 'Record', 'abientot' ) . ' ' . esc_html__( 'Details', 'abientot' ) . ' ' . esc_html__( 'Action', 'abientot' ) . ' ';
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 '' . 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 ) : '' ) . ' ';
}
echo '
';
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' ) ) . '
';
foreach ( $assessment['blocked'] as $record ) {
echo '' . esc_html( $record['kind'] . ' #' . $record['id'] ) . ' ';
}
echo ' ';
} 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 '';
}
}
}