$analysis['customers'], 'services' => array_values( $analysis['services'] ), 'appointments' => $analysis['appointments'] ) as $kind => $rows ) { foreach ( array_chunk( $rows, Csv::ROWS ) as $rows_chunk ) { self::write( $id . '_' . $n, array( 'kind' => $kind, 'rows' => $rows_chunk ) ); $chunks[] = array( 'n' => $n, 'kind' => $kind, 'count' => count( $rows_chunk ), 'state' => 'pending' ); ++$n; } } $past = count( array_filter( $analysis['appointments'], static fn( $row ) => $row['historical'] ) ); $run = array( 'user' => get_current_user_id(), 'namespace' => self::source_namespace(), 'state' => 'mapping', 'timezone' => $timezone, 'version' => $analysis['version'], 'addons' => $analysis['addons'], 'addon_tables' => $analysis['addon_tables'], 'uncarried' => $analysis['uncarried'], 'source_currency' => $analysis['source_currency'], 'staff' => $analysis['staff'], 'service_staff' => array_map( static fn( $service ) => $service['staff'], $analysis['services'] ), 'staff_counts' => array_count_values( array_map( static fn( $row ) => (string) $row['staff'], $analysis['appointments'] ) ), 'planned' => array( 'customers' => count( $analysis['customers'] ), 'services' => count( $analysis['services'] ), 'past' => $past, 'future' => count( $analysis['appointments'] ) - $past ), 'imported' => array( 'customers' => 0, 'services' => 0, 'appointments' => 0, 'earlier' => 0 ), 'chunks' => $chunks, 'mapping' => array(), 'service_ids' => array(), 'batches' => array(), 'links' => array(), 'blocked' => array(), 'skipped' => array(), 'reasons' => array() ); foreach ( $analysis['skipped'] as $item ) { self::skip( $run, $item['type'], $item['label'], $item['reason'] ); } self::write( $id, $run ); return $id; } /** * Source keys stay reserved after a rollback, so a fully undone Bookly import moves to a new * generation; until then re-runs share one namespace and only add what is missing. */ public static function source_namespace(): string { $ledger = get_option( self::LEDGER, array() ); $gen = is_array( $ledger ) ? max( 0, (int) ( $ledger['generation'] ?? 0 ) ) : 0; return self::NAMESPACE . ( $gen ? ':' . $gen : '' ); } public static function remember_batch( string $uuid ): void { $ledger = get_option( self::LEDGER, array() ); $ledger = is_array( $ledger ) ? $ledger : array(); $ledger['batches'] = array_values( array_unique( array_merge( (array) ( $ledger['batches'] ?? array() ), array( $uuid ) ) ) ); update_option( self::LEDGER, $ledger, false ); } /** Starts a new generation once no committed batch of the current one remains. */ public static function release( Repository $repository ): void { $ledger = get_option( self::LEDGER, array() ); $ledger = is_array( $ledger ) ? $ledger : array(); if ( empty( $ledger['batches'] ) ) { return; } foreach ( (array) ( $ledger['batches'] ?? array() ) as $uuid ) { $batch = is_string( $uuid ) ? $repository->batch( $uuid ) : null; if ( $batch && $batch['state'] === 'committed' ) { return; } } update_option( self::LEDGER, array( 'generation' => (int) ( $ledger['generation'] ?? 0 ) + 1, 'batches' => array() ), false ); } public static function skip( array &$run, string $type, string $label, string $reason ): void { $run['reasons'][ $reason ] = ( $run['reasons'][ $reason ] ?? 0 ) + 1; if ( count( $run['skipped'] ) < self::SKIPPED ) { $run['skipped'][] = array( 'type' => $type, 'label' => $label, 'reason' => $reason ); } } public static function valid_id( $id ): bool { return is_string( $id ) && preg_match( '/^[a-f0-9]{16}$/D', $id ); } public static function get( string $id ): array { $run = self::valid_id( $id ) ? get_transient( self::PREFIX . $id ) : false; if ( ! is_array( $run ) || (int) ( $run['user'] ?? 0 ) !== get_current_user_id() || get_current_user_id() < 1 ) { throw new \DomainException( 'import_token' ); } return $run; } public static function save( string $id, array $run ): void { self::write( $id, $run ); } public static function chunk( string $id, int $n ): array { self::get( $id ); $chunk = get_transient( self::PREFIX . $id . '_' . $n ); if ( ! is_array( $chunk ) || ! is_array( $chunk['rows'] ?? null ) ) { throw new \DomainException( 'import_token' ); } return $chunk; } public static function save_chunk( string $id, int $n, array $chunk ): void { self::get( $id ); self::write( $id . '_' . $n, $chunk ); } public static function discard( string $id ): void { $run = self::get( $id ); foreach ( $run['chunks'] as $chunk ) { delete_transient( self::PREFIX . $id . '_' . (int) $chunk['n'] ); } delete_transient( self::PREFIX . $id ); } public static function drop_chunk( string $id, int $n ): void { delete_transient( self::PREFIX . $id . '_' . $n ); } private static function write( string $key, array $value ): void { delete_transient( self::PREFIX . $key ); if ( ! set_transient( self::PREFIX . $key, $value, self::TTL ) ) { throw new \RuntimeException( 'import_storage' ); } } public static function reference( string $id, int $n, string $kind ): string { return $id . ':' . $n . ':' . $kind; } /** Canonical CSV of a staged chunk; the only input accepted for the bookly_*_v1 formats. */ public static function normalize( string $format, string $reference ): array { if ( ! preg_match( '/^bookly_(customers|services|appointments)_v1$/D', $format, $kind ) || ! preg_match( '/^([a-f0-9]{16}):([0-9]{1,5}):(customers|services|appointments)$/D', $reference, $parts ) || $parts[3] !== $kind[1] ) { throw new \DomainException( 'import_source_format' ); } $chunk = self::chunk( $parts[1], (int) $parts[2] ); if ( $chunk['kind'] !== $kind[1] || ! $chunk['rows'] || count( $chunk['rows'] ) > Csv::ROWS ) { throw new \DomainException( 'import_source_format' ); } $rows = Bookly::canonical( $kind[1], array_map( static fn( $row ) => $row['row'] ?? $row, $chunk['rows'] ) ); $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 { fputcsv( $stream, Csv::fields( $kind[1] ), ',', '"', '' ); foreach ( $rows as $row ) { fputcsv( $stream, array_values( $row ), ',', '"', '' ); } rewind( $stream ); $csv = stream_get_contents( $stream ); } finally { fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the in-memory php://temp stream. } if ( ! is_string( $csv ) ) { throw new \RuntimeException( 'import_storage' ); } Csv::parse( $csv, ',' ); return array( 'csv' => $csv, 'metadata' => array( 'format' => $format, 'kind' => $kind[1], 'edition' => 'shared', 'scope' => 'bookly_export' ) ); } }