DataTransferService.php
1048 lines
| 1 | <?php |
| 2 | |
| 3 | namespace AmeliaBooking\Infrastructure\Services\DataTransfer; |
| 4 | |
| 5 | use AmeliaBooking\Domain\Services\Settings\SettingsService; |
| 6 | use AmeliaBooking\Infrastructure\Common\Container; |
| 7 | use AmeliaBooking\Infrastructure\Connection; |
| 8 | use AmeliaBooking\Infrastructure\WP\InstallActions\DB; |
| 9 | use AmeliaBooking\Infrastructure\WP\InstallActions\DB\Gallery\GalleriesTable; |
| 10 | use AmeliaBooking\Infrastructure\WP\InstallActions\DB\User\UsersTable; |
| 11 | use AmeliaBooking\Infrastructure\WP\UserService\CreateWPUser; |
| 12 | use RuntimeException; |
| 13 | use AmeliaVendor\Psr\Http\Message\UploadedFileInterface as UploadedFile; |
| 14 | use ZipArchive; |
| 15 | |
| 16 | /** |
| 17 | * Class DataTransferService |
| 18 | * |
| 19 | * @package AmeliaBooking\Infrastructure\Services\DataTransfer |
| 20 | */ |
| 21 | class DataTransferService |
| 22 | { |
| 23 | private const JOB_OPTION_PREFIX = 'amelia_data_transfer_job_'; |
| 24 | |
| 25 | private const FORMAT_VERSION = 1; |
| 26 | |
| 27 | /** Rows read and inserted per API call */ |
| 28 | private const IMPORT_ROWS_PER_API_CALL = 1000; |
| 29 | |
| 30 | /** Rows per multi-row INSERT SQL statement. */ |
| 31 | private const IMPORT_INSERT_ROWS = 100; |
| 32 | |
| 33 | /** @var Container */ |
| 34 | private $container; |
| 35 | |
| 36 | /** @var Connection */ |
| 37 | private $connection; |
| 38 | |
| 39 | /** @var SettingsService */ |
| 40 | private $settingsService; |
| 41 | |
| 42 | /** @var array<string, array<int, string>> */ |
| 43 | private $columnCache = []; |
| 44 | |
| 45 | public function __construct(Container $container) |
| 46 | { |
| 47 | $this->container = $container; |
| 48 | $this->connection = $container->getDatabaseConnection(); |
| 49 | $this->settingsService = $container->get('domain.settings.service'); |
| 50 | } |
| 51 | |
| 52 | public function exportArchive(): array |
| 53 | { |
| 54 | $this->assertZipArchiveAvailable(); |
| 55 | |
| 56 | $jobId = wp_generate_uuid4(); |
| 57 | $workingDirectory = $this->createWorkingDirectory($jobId); |
| 58 | $archivePath = $workingDirectory . '/amelia-export.ameliafile'; |
| 59 | |
| 60 | $zip = new ZipArchive(); |
| 61 | |
| 62 | if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { |
| 63 | $this->deleteDirectory($workingDirectory); |
| 64 | |
| 65 | throw new RuntimeException('Unable to create the Amelia export archive.'); |
| 66 | } |
| 67 | |
| 68 | $metadata = [ |
| 69 | 'formatVersion' => self::FORMAT_VERSION, |
| 70 | 'pluginVersion' => defined('AMELIA_VERSION') ? AMELIA_VERSION : null, |
| 71 | 'exportedAt' => gmdate('c'), |
| 72 | 'exportedEntities' => [ |
| 73 | 'settings', |
| 74 | 'locations', |
| 75 | 'customers', |
| 76 | 'employees', |
| 77 | 'categories', |
| 78 | 'services', |
| 79 | 'extras', |
| 80 | 'packages', |
| 81 | 'resources', |
| 82 | 'notifications', |
| 83 | 'appointments', |
| 84 | 'events', |
| 85 | 'payments', |
| 86 | 'coupons', |
| 87 | 'taxes', |
| 88 | 'customFields', |
| 89 | ], |
| 90 | 'datasets' => [], |
| 91 | ]; |
| 92 | |
| 93 | $settingsPath = 'settings/settings.json'; |
| 94 | $sanitizedSettings = $this->getSanitizedSettings(); |
| 95 | $zip->addFromString($settingsPath, json_encode(['data' => $sanitizedSettings])); |
| 96 | $metadata['datasets'][] = [ |
| 97 | 'key' => 'settings', |
| 98 | 'path' => $settingsPath, |
| 99 | 'records' => 1, |
| 100 | ]; |
| 101 | |
| 102 | foreach ($this->getDatasetDefinitions() as $dataset) { |
| 103 | $rows = $this->getDatasetRows($dataset); |
| 104 | $relativePath = $dataset['group'] . '/' . $dataset['file']; |
| 105 | |
| 106 | $zip->addFromString($relativePath, json_encode(['data' => $rows])); |
| 107 | |
| 108 | $metadata['datasets'][] = [ |
| 109 | 'key' => $dataset['key'], |
| 110 | 'path' => $relativePath, |
| 111 | 'records' => count($rows), |
| 112 | ]; |
| 113 | } |
| 114 | |
| 115 | $zip->addFromString('metadata.json', json_encode($metadata)); |
| 116 | $zip->close(); |
| 117 | |
| 118 | $content = file_get_contents($archivePath); |
| 119 | |
| 120 | $this->deleteDirectory($workingDirectory); |
| 121 | |
| 122 | if ($content === false) { |
| 123 | throw new RuntimeException('Unable to read the Amelia export archive.'); |
| 124 | } |
| 125 | |
| 126 | return [ |
| 127 | 'fileName' => 'amelia-export-' . gmdate('Ymd-His') . '.ameliafile', |
| 128 | 'mimeType' => 'application/octet-stream', |
| 129 | 'archive' => base64_encode($content), |
| 130 | 'metadata' => $metadata, |
| 131 | ]; |
| 132 | } |
| 133 | |
| 134 | public function startImportJob(UploadedFile $file): array |
| 135 | { |
| 136 | $this->assertZipArchiveAvailable(); |
| 137 | |
| 138 | if ($file->getError() !== UPLOAD_ERR_OK) { |
| 139 | throw new RuntimeException('Unable to upload the Amelia archive.'); |
| 140 | } |
| 141 | |
| 142 | $clientFilename = $file->getClientFilename() ?: 'amelia-export.ameliafile'; |
| 143 | |
| 144 | if (!$this->hasAmeliaFileExtension($clientFilename)) { |
| 145 | throw new RuntimeException('The uploaded file must use the .ameliafile extension.'); |
| 146 | } |
| 147 | |
| 148 | $jobId = wp_generate_uuid4(); |
| 149 | $workingDirectory = $this->createWorkingDirectory($jobId); |
| 150 | $archivePath = $workingDirectory . '/import.ameliafile'; |
| 151 | $extractDirectory = $workingDirectory . '/extract'; |
| 152 | |
| 153 | wp_mkdir_p($extractDirectory); |
| 154 | $file->moveTo($archivePath); |
| 155 | |
| 156 | $metadata = $this->extractAndValidateArchive($archivePath, $extractDirectory); |
| 157 | $steps = $this->buildImportSteps($metadata, $extractDirectory); |
| 158 | |
| 159 | $job = [ |
| 160 | 'id' => $jobId, |
| 161 | 'status' => 'pending', |
| 162 | 'archivePath' => $archivePath, |
| 163 | 'extractDirectory' => $extractDirectory, |
| 164 | 'createdAt' => gmdate('c'), |
| 165 | 'updatedAt' => gmdate('c'), |
| 166 | 'currentStep' => 0, |
| 167 | 'processedRows' => 0, |
| 168 | 'totalRows' => array_sum(array_column($steps, 'records')) ?: 1, |
| 169 | 'tablesTruncated' => false, |
| 170 | 'steps' => $steps, |
| 171 | 'summary' => [ |
| 172 | 'inserted' => 0, |
| 173 | 'skipped' => 0, |
| 174 | ], |
| 175 | 'errors' => [], |
| 176 | 'metadata' => $metadata, |
| 177 | ]; |
| 178 | |
| 179 | $this->saveJob($job); |
| 180 | |
| 181 | return $this->formatJobStatus($job); |
| 182 | } |
| 183 | |
| 184 | public function processImportJob(string $jobId): array |
| 185 | { |
| 186 | $job = $this->getJob($jobId); |
| 187 | |
| 188 | if (!$job) { |
| 189 | throw new RuntimeException('The Amelia import job could not be found.'); |
| 190 | } |
| 191 | |
| 192 | if (in_array($job['status'], ['completed', 'failed'], true)) { |
| 193 | return $this->formatJobStatus($job); |
| 194 | } |
| 195 | |
| 196 | $this->normalizeJobStepRecordCounts($job); |
| 197 | |
| 198 | $job['status'] = 'processing'; |
| 199 | $job['updatedAt'] = gmdate('c'); |
| 200 | |
| 201 | try { |
| 202 | if (empty($job['tablesTruncated'])) { |
| 203 | $this->truncateAllTables(); |
| 204 | $job['tablesTruncated'] = true; |
| 205 | } |
| 206 | |
| 207 | $idx = (int) $job['currentStep']; |
| 208 | |
| 209 | if (!isset($job['steps'][$idx])) { |
| 210 | $job['status'] = 'completed'; |
| 211 | $job['updatedAt'] = gmdate('c'); |
| 212 | $this->cleanupJobFiles($job); |
| 213 | |
| 214 | return $this->formatJobStatus($job); |
| 215 | } |
| 216 | |
| 217 | $step = $job['steps'][$idx]; |
| 218 | $rowBudget = self::IMPORT_ROWS_PER_API_CALL; |
| 219 | |
| 220 | while ($rowBudget > 0 && isset($job['steps'][$idx])) { |
| 221 | $step = $job['steps'][$idx]; |
| 222 | |
| 223 | if (($step['key'] ?? '') === 'settings') { |
| 224 | $settings = $this->readJsonData($job['extractDirectory'] . '/' . $step['path']); |
| 225 | $this->importSettings($settings); |
| 226 | $job['processedRows'] += 1; |
| 227 | $job['steps'][$idx]['processed'] = 1; |
| 228 | $idx++; |
| 229 | $job['currentStep'] = $idx; |
| 230 | $rowBudget--; |
| 231 | |
| 232 | continue; |
| 233 | } |
| 234 | |
| 235 | $rows = $this->readJsonData($job['extractDirectory'] . '/' . $step['path']); |
| 236 | $actualRecords = count($rows); |
| 237 | $configuredRecords = (int) ($job['steps'][$idx]['records'] ?? 0); |
| 238 | |
| 239 | // Self-heal legacy jobs whose step totals were initialized incorrectly. |
| 240 | if ($configuredRecords !== $actualRecords) { |
| 241 | $job['steps'][$idx]['records'] = $actualRecords; |
| 242 | $job['totalRows'] = max(1, ((int) $job['totalRows']) - $configuredRecords + $actualRecords); |
| 243 | } |
| 244 | |
| 245 | $offset = (int) $job['steps'][$idx]['processed']; |
| 246 | $records = (int) $job['steps'][$idx]['records']; |
| 247 | $remainingInStep = $records - $offset; |
| 248 | |
| 249 | if ($remainingInStep <= 0) { |
| 250 | $idx++; |
| 251 | $job['currentStep'] = $idx; |
| 252 | |
| 253 | continue; |
| 254 | } |
| 255 | |
| 256 | $take = min($rowBudget, $remainingInStep); |
| 257 | $chunk = array_slice($rows, $offset, $take); |
| 258 | |
| 259 | $this->importDatasetRows($step, $chunk, $job); |
| 260 | |
| 261 | $processedCount = count($chunk); |
| 262 | $job['processedRows'] += $processedCount; |
| 263 | $job['steps'][$idx]['processed'] = $offset + $processedCount; |
| 264 | $rowBudget -= $processedCount; |
| 265 | |
| 266 | if ($job['steps'][$idx]['processed'] >= $job['steps'][$idx]['records']) { |
| 267 | $idx++; |
| 268 | $job['currentStep'] = $idx; |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | if ($job['currentStep'] >= count($job['steps'])) { |
| 273 | $job['status'] = 'completed'; |
| 274 | $this->cleanupJobFiles($job); |
| 275 | } |
| 276 | } catch (\Exception $e) { |
| 277 | $this->appendImportError( |
| 278 | $job, |
| 279 | sprintf('Dataset %s: %s', $step['key'] ?? 'unknown', $e->getMessage()) |
| 280 | ); |
| 281 | $job['status'] = 'failed'; |
| 282 | $this->cleanupJobFiles($job, false); |
| 283 | } |
| 284 | |
| 285 | $job['updatedAt'] = gmdate('c'); |
| 286 | |
| 287 | if ($job['status'] !== 'completed') { |
| 288 | $this->saveJob($job); |
| 289 | } |
| 290 | |
| 291 | return $this->formatJobStatus($job); |
| 292 | } |
| 293 | |
| 294 | private function normalizeJobStepRecordCounts(array &$job): void |
| 295 | { |
| 296 | if (empty($job['extractDirectory']) || !is_dir($job['extractDirectory']) || empty($job['steps'])) { |
| 297 | return; |
| 298 | } |
| 299 | |
| 300 | foreach ($job['steps'] as $index => $step) { |
| 301 | if (($step['key'] ?? '') === 'settings') { |
| 302 | $job['steps'][$index]['records'] = 1; |
| 303 | continue; |
| 304 | } |
| 305 | |
| 306 | if (empty($step['path'])) { |
| 307 | continue; |
| 308 | } |
| 309 | |
| 310 | $path = $job['extractDirectory'] . '/' . ltrim((string) $step['path'], '/'); |
| 311 | |
| 312 | if (!file_exists($path)) { |
| 313 | continue; |
| 314 | } |
| 315 | |
| 316 | $job['steps'][$index]['records'] = count($this->readJsonData($path)); |
| 317 | } |
| 318 | |
| 319 | $job['totalRows'] = max(1, array_sum(array_map(function ($step) { |
| 320 | return (int) ($step['records'] ?? 0); |
| 321 | }, $job['steps']))); |
| 322 | } |
| 323 | |
| 324 | private function getDatasetDefinitions(): array |
| 325 | { |
| 326 | return [ |
| 327 | ['key' => 'locations', 'group' => 'locations', 'file' => 'locations.json', |
| 328 | 'tableClass' => DB\Location\LocationsTable::class], |
| 329 | ['key' => 'customers', 'group' => 'users', 'file' => 'customers.json', 'type' => 'users', |
| 330 | 'userType' => 'customer', 'tableClass' => DB\User\UsersTable::class], |
| 331 | ['key' => 'employees', 'group' => 'users', 'file' => 'employees.json', 'type' => 'users', |
| 332 | 'userType' => 'provider', 'tableClass' => DB\User\UsersTable::class], |
| 333 | ['key' => 'providersOutlookCalendar', 'group' => 'schedules', 'file' => 'providers-outlook-calendar.json', |
| 334 | 'tableClass' => DB\User\Provider\ProvidersOutlookCalendarTable::class], |
| 335 | ['key' => 'providersGoogleCalendar', 'group' => 'schedules', 'file' => 'providers-google-calendar.json', |
| 336 | 'tableClass' => DB\User\Provider\ProvidersGoogleCalendarTable::class], |
| 337 | ['key' => 'providersLocations', 'group' => 'schedules', 'file' => 'providers-locations.json', |
| 338 | 'tableClass' => DB\User\Provider\ProvidersLocationTable::class], |
| 339 | ['key' => 'providersWeekDays', 'group' => 'schedules', 'file' => 'providers-week-days.json', |
| 340 | 'tableClass' => DB\User\Provider\ProvidersWeekDayTable::class], |
| 341 | ['key' => 'providersDaysOff', 'group' => 'schedules', 'file' => 'providers-days-off.json', |
| 342 | 'tableClass' => DB\User\Provider\ProvidersDayOffTable::class], |
| 343 | ['key' => 'providersSpecialDays', 'group' => 'schedules', 'file' => 'providers-special-days.json', |
| 344 | 'tableClass' => DB\User\Provider\ProvidersSpecialDayTable::class], |
| 345 | ['key' => 'providersTimeOut', 'group' => 'schedules', 'file' => 'providers-time-out.json', |
| 346 | 'tableClass' => DB\User\Provider\ProvidersTimeOutTable::class], |
| 347 | ['key' => 'providersPeriods', 'group' => 'schedules', 'file' => 'providers-periods.json', |
| 348 | 'tableClass' => DB\User\Provider\ProvidersPeriodTable::class], |
| 349 | ['key' => 'providersSpecialDayPeriods', 'group' => 'schedules', 'file' => 'providers-special-day-periods.json', |
| 350 | 'tableClass' => DB\User\Provider\ProvidersSpecialDayPeriodTable::class], |
| 351 | ['key' => 'categories', 'group' => 'catalog', 'file' => 'categories.json', |
| 352 | 'tableClass' => DB\Bookable\CategoriesTable::class], |
| 353 | ['key' => 'services', 'group' => 'catalog', 'file' => 'services.json', |
| 354 | 'tableClass' => DB\Bookable\ServicesTable::class], |
| 355 | ['key' => 'notifications', 'group' => 'notifications', 'file' => 'notifications.json', |
| 356 | 'tableClass' => DB\Notification\NotificationsTable::class], |
| 357 | ['key' => 'notificationsToEntities', 'group' => 'notifications', 'file' => 'notifications-to-entities.json', |
| 358 | 'tableClass' => DB\Notification\NotificationsToEntitiesTable::class], |
| 359 | ['key' => 'providersPeriodServices', 'group' => 'catalog', 'file' => 'providers-period-services.json', |
| 360 | 'tableClass' => DB\User\Provider\ProvidersPeriodServiceTable::class], |
| 361 | ['key' => 'providersPeriodLocations', 'group' => 'catalog', 'file' => 'providers-period-locations.json', |
| 362 | 'tableClass' => DB\User\Provider\ProvidersPeriodLocationTable::class], |
| 363 | ['key' => 'providersSpecialDayPeriodServices', 'group' => 'catalog', 'file' => 'providers-special-day-period-services.json', |
| 364 | 'tableClass' => DB\User\Provider\ProvidersSpecialDayPeriodServiceTable::class], |
| 365 | ['key' => 'providersSpecialDayPeriodLocations', 'group' => 'catalog', 'file' => 'providers-special-day-period-locations.json', |
| 366 | 'tableClass' => DB\User\Provider\ProvidersSpecialDayPeriodLocationTable::class], |
| 367 | ['key' => 'providersServices', 'group' => 'catalog', 'file' => 'providers-services.json', |
| 368 | 'tableClass' => DB\User\Provider\ProvidersServiceTable::class], |
| 369 | ['key' => 'extras', 'group' => 'catalog', 'file' => 'extras.json', |
| 370 | 'tableClass' => DB\Bookable\ExtrasTable::class], |
| 371 | ['key' => 'packages', 'group' => 'catalog', 'file' => 'packages.json', |
| 372 | 'tableClass' => DB\Bookable\PackagesTable::class], |
| 373 | ['key' => 'coupons', 'group' => 'finance', 'file' => 'coupons.json', |
| 374 | 'tableClass' => DB\Coupon\CouponsTable::class], |
| 375 | ['key' => 'couponsToPackages', 'group' => 'finance', 'file' => 'coupons-to-packages.json', |
| 376 | 'tableClass' => DB\Coupon\CouponsToPackagesTable::class], |
| 377 | ['key' => 'packageServices', 'group' => 'catalog', 'file' => 'package-services.json', |
| 378 | 'tableClass' => DB\Bookable\PackagesServicesTable::class], |
| 379 | ['key' => 'packageServicesProviders', 'group' => 'catalog', 'file' => 'package-services-providers.json', |
| 380 | 'tableClass' => DB\Bookable\PackagesServicesProvidersTable::class], |
| 381 | ['key' => 'packageServicesLocations', 'group' => 'catalog', 'file' => 'package-services-locations.json', |
| 382 | 'tableClass' => DB\Bookable\PackagesServicesLocationsTable::class], |
| 383 | ['key' => 'packageCustomers', 'group' => 'bookings', 'file' => 'package-customers.json', |
| 384 | 'tableClass' => DB\Bookable\PackagesCustomersTable::class], |
| 385 | ['key' => 'packageCustomersServices', 'group' => 'bookings', 'file' => 'package-customers-services.json', |
| 386 | 'tableClass' => DB\Bookable\PackagesCustomersServicesTable::class], |
| 387 | ['key' => 'resources', 'group' => 'catalog', 'file' => 'resources.json', |
| 388 | 'tableClass' => DB\Bookable\ResourcesTable::class], |
| 389 | ['key' => 'resourcesToEntities', 'group' => 'catalog', 'file' => 'resources-to-entities.json', |
| 390 | 'tableClass' => DB\Bookable\ResourcesToEntitiesTable::class], |
| 391 | ['key' => 'appointments', 'group' => 'bookings', 'file' => 'appointments.json', |
| 392 | 'tableClass' => DB\Booking\AppointmentsTable::class], |
| 393 | ['key' => 'events', 'group' => 'events', 'file' => 'events.json', |
| 394 | 'tableClass' => DB\Booking\EventsTable::class], |
| 395 | ['key' => 'eventsTags', 'group' => 'events', 'file' => 'event-tags.json', |
| 396 | 'tableClass' => DB\Booking\EventsTagsTable::class], |
| 397 | ['key' => 'eventsTickets', 'group' => 'events', 'file' => 'event-tickets.json', |
| 398 | 'tableClass' => DB\Booking\EventsTicketsTable::class], |
| 399 | ['key' => 'eventsPeriods', 'group' => 'events', 'file' => 'event-periods.json', |
| 400 | 'tableClass' => DB\Booking\EventsPeriodsTable::class], |
| 401 | ['key' => 'eventsProviders', 'group' => 'events', 'file' => 'event-providers.json', |
| 402 | 'tableClass' => DB\Booking\EventsProvidersTable::class], |
| 403 | ['key' => 'customerBookings', 'group' => 'bookings', 'file' => 'customer-bookings.json', |
| 404 | 'tableClass' => DB\Booking\CustomerBookingsTable::class], |
| 405 | ['key' => 'customerBookingsToExtras', 'group' => 'bookings', 'file' => 'customer-bookings-to-extras.json', |
| 406 | 'tableClass' => DB\Booking\CustomerBookingsToExtrasTable::class], |
| 407 | ['key' => 'customerBookingsToEventsPeriods', 'group' => 'events', 'file' => 'customer-bookings-to-event-periods.json', |
| 408 | 'tableClass' => DB\Booking\CustomerBookingsToEventsPeriodsTable::class], |
| 409 | ['key' => 'customerBookingsToEventsTickets', 'group' => 'events', 'file' => 'customer-bookings-to-event-tickets.json', |
| 410 | 'tableClass' => DB\Booking\CustomerBookingToEventsTicketsTable::class], |
| 411 | ['key' => 'notificationsLog', 'group' => 'notifications', 'file' => 'notifications-log.json', |
| 412 | 'tableClass' => DB\Notification\NotificationsLogTable::class], |
| 413 | ['key' => 'notificationsSmsHistory', 'group' => 'notifications', 'file' => 'notifications-sms-history.json', |
| 414 | 'tableClass' => DB\Notification\NotificationsSMSHistoryTable::class], |
| 415 | ['key' => 'payments', 'group' => 'finance', 'file' => 'payments.json', |
| 416 | 'tableClass' => DB\Payment\PaymentsTable::class], |
| 417 | ['key' => 'couponsToServices', 'group' => 'finance', 'file' => 'coupons-to-services.json', |
| 418 | 'tableClass' => DB\Coupon\CouponsToServicesTable::class], |
| 419 | ['key' => 'couponsToEvents', 'group' => 'finance', 'file' => 'coupons-to-events.json', |
| 420 | 'tableClass' => DB\Coupon\CouponsToEventsTable::class], |
| 421 | ['key' => 'customFields', 'group' => 'custom-fields', 'file' => 'custom-fields.json', |
| 422 | 'tableClass' => DB\CustomField\CustomFieldsTable::class], |
| 423 | ['key' => 'customFieldsOptions', 'group' => 'custom-fields', 'file' => 'custom-field-options.json', |
| 424 | 'tableClass' => DB\CustomField\CustomFieldsOptionsTable::class], |
| 425 | ['key' => 'customFieldsServices', 'group' => 'custom-fields', 'file' => 'custom-field-services.json', |
| 426 | 'tableClass' => DB\CustomField\CustomFieldsServicesTable::class], |
| 427 | ['key' => 'customFieldsEvents', 'group' => 'custom-fields', 'file' => 'custom-field-events.json', |
| 428 | 'tableClass' => DB\CustomField\CustomFieldsEventsTable::class], |
| 429 | ['key' => 'taxes', 'group' => 'finance', 'file' => 'taxes.json', |
| 430 | 'tableClass' => DB\Tax\TaxesTable::class], |
| 431 | ['key' => 'taxesToEntities', 'group' => 'finance', 'file' => 'taxes-to-entities.json', |
| 432 | 'tableClass' => DB\Tax\TaxesToEntitiesTable::class], |
| 433 | ]; |
| 434 | } |
| 435 | |
| 436 | private function getDatasetRows(array $dataset): array |
| 437 | { |
| 438 | if (($dataset['type'] ?? null) === 'users') { |
| 439 | $userType = $dataset['userType']; |
| 440 | $table = UsersTable::getTableName(); |
| 441 | $statement = $this->connection->query( |
| 442 | "SELECT * FROM `{$table}` WHERE `type` = '" . esc_sql($userType) . "'" |
| 443 | ); |
| 444 | |
| 445 | return array_map(function ($row) { |
| 446 | return $this->sanitizeDatasetRow($row); |
| 447 | }, $statement->fetchAll()); |
| 448 | } |
| 449 | |
| 450 | $table = $this->getTableNameFromDataset($dataset); |
| 451 | $statement = $this->connection->query("SELECT * FROM `{$table}`"); |
| 452 | |
| 453 | return array_map(function ($row) { |
| 454 | return $this->sanitizeDatasetRow($row); |
| 455 | }, $statement->fetchAll()); |
| 456 | } |
| 457 | |
| 458 | private function sanitizeDatasetRow(array $row): array |
| 459 | { |
| 460 | $sanitized = $row; |
| 461 | |
| 462 | foreach ($sanitized as $column => $value) { |
| 463 | if ($this->isImageColumn($column)) { |
| 464 | $sanitized[$column] = ''; |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | return $sanitized; |
| 469 | } |
| 470 | |
| 471 | private function getSanitizedSettings(): array |
| 472 | { |
| 473 | $settings = $this->settingsService->getAllSettingsCategorized(); |
| 474 | |
| 475 | unset($settings['activation'], $settings['wordpress']); |
| 476 | |
| 477 | return $this->sanitizeSettingsArray($settings); |
| 478 | } |
| 479 | |
| 480 | private function sanitizeSettingsArray(array $settings): array |
| 481 | { |
| 482 | foreach ($settings as $key => $value) { |
| 483 | if (is_array($value)) { |
| 484 | $settings[$key] = $this->sanitizeSettingsArray($value); |
| 485 | continue; |
| 486 | } |
| 487 | |
| 488 | if (!is_string($value)) { |
| 489 | continue; |
| 490 | } |
| 491 | |
| 492 | if ($this->isImageColumn($key)) { |
| 493 | $settings[$key] = ''; |
| 494 | continue; |
| 495 | } |
| 496 | |
| 497 | if (preg_match('/(url|uri)$/i', $key) || preg_match('/(url|uri)/i', $key)) { |
| 498 | $settings[$key] = ''; |
| 499 | } |
| 500 | } |
| 501 | |
| 502 | return $settings; |
| 503 | } |
| 504 | |
| 505 | private function extractAndValidateArchive(string $archivePath, string $extractDirectory): array |
| 506 | { |
| 507 | $zip = new ZipArchive(); |
| 508 | |
| 509 | if ($zip->open($archivePath) !== true) { |
| 510 | throw new RuntimeException('The Amelia archive could not be opened.'); |
| 511 | } |
| 512 | |
| 513 | if (!$zip->extractTo($extractDirectory)) { |
| 514 | $zip->close(); |
| 515 | throw new RuntimeException('The Amelia archive could not be extracted.'); |
| 516 | } |
| 517 | |
| 518 | $zip->close(); |
| 519 | |
| 520 | $metadataPath = $extractDirectory . '/metadata.json'; |
| 521 | |
| 522 | if (!file_exists($metadataPath)) { |
| 523 | throw new RuntimeException('The Amelia archive is missing its metadata.json file.'); |
| 524 | } |
| 525 | |
| 526 | $metadata = $this->readJsonFile($metadataPath); |
| 527 | |
| 528 | if (($metadata['formatVersion'] ?? null) !== self::FORMAT_VERSION) { |
| 529 | throw new RuntimeException('The Amelia archive format is not supported by this version of the plugin.'); |
| 530 | } |
| 531 | |
| 532 | $this->assertMatchingPluginVersion($metadata); |
| 533 | $this->assertExpectedDatasetsPresent($metadata); |
| 534 | |
| 535 | foreach (($metadata['datasets'] ?? []) as $dataset) { |
| 536 | if (empty($dataset['path'])) { |
| 537 | throw new RuntimeException('The Amelia archive metadata is incomplete.'); |
| 538 | } |
| 539 | |
| 540 | $filePath = $extractDirectory . '/' . ltrim($dataset['path'], '/'); |
| 541 | |
| 542 | if (!file_exists($filePath)) { |
| 543 | throw new RuntimeException('The Amelia archive is missing one or more JSON datasets.'); |
| 544 | } |
| 545 | |
| 546 | $this->readJsonFile($filePath); |
| 547 | } |
| 548 | |
| 549 | return $metadata; |
| 550 | } |
| 551 | |
| 552 | private function buildImportSteps(array $metadata, string $extractDirectory): array |
| 553 | { |
| 554 | $metadataIndex = []; |
| 555 | |
| 556 | foreach (($metadata['datasets'] ?? []) as $dataset) { |
| 557 | $metadataIndex[$dataset['key']] = $dataset; |
| 558 | } |
| 559 | |
| 560 | $steps = []; |
| 561 | |
| 562 | foreach ($this->getDatasetDefinitions() as $datasetDefinition) { |
| 563 | $datasetMetadata = $metadataIndex[$datasetDefinition['key']]; |
| 564 | $datasetPath = $datasetMetadata['path']; |
| 565 | $datasetData = $this->readJsonData($extractDirectory . '/' . ltrim($datasetPath, '/')); |
| 566 | |
| 567 | $steps[] = array_merge($datasetDefinition, [ |
| 568 | 'path' => $datasetPath, |
| 569 | // Recalculate records from payload to avoid stale/incorrect metadata counts. |
| 570 | 'records' => count($datasetData), |
| 571 | 'processed' => 0, |
| 572 | ]); |
| 573 | } |
| 574 | |
| 575 | $steps[] = [ |
| 576 | 'key' => 'settings', |
| 577 | 'group' => 'settings', |
| 578 | 'file' => 'settings.json', |
| 579 | 'path' => $metadataIndex['settings']['path'], |
| 580 | 'records' => 1, |
| 581 | 'processed' => 0, |
| 582 | ]; |
| 583 | |
| 584 | if (!$steps) { |
| 585 | throw new RuntimeException('The Amelia archive does not contain any supported datasets.'); |
| 586 | } |
| 587 | |
| 588 | foreach ($steps as $step) { |
| 589 | $filePath = $extractDirectory . '/' . $step['path']; |
| 590 | |
| 591 | if (!file_exists($filePath)) { |
| 592 | throw new RuntimeException('One of the Amelia import datasets is missing.'); |
| 593 | } |
| 594 | } |
| 595 | |
| 596 | return $steps; |
| 597 | } |
| 598 | |
| 599 | private function assertMatchingPluginVersion(array $metadata): void |
| 600 | { |
| 601 | $archivePluginVersion = (string) ($metadata['pluginVersion'] ?? ''); |
| 602 | $currentPluginVersion = defined('AMELIA_VERSION') ? (string) AMELIA_VERSION : ''; |
| 603 | |
| 604 | if ($archivePluginVersion === '' || $currentPluginVersion === '' || $archivePluginVersion !== $currentPluginVersion) { |
| 605 | throw new RuntimeException('The Amelia archive was created with a different plugin version and cannot be imported.'); |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | private function assertExpectedDatasetsPresent(array $metadata): void |
| 610 | { |
| 611 | $expectedKeys = $this->getExpectedImportDatasetKeys(); |
| 612 | $metadataKeys = array_values(array_unique(array_map(function ($dataset) { |
| 613 | return (string) ($dataset['key'] ?? ''); |
| 614 | }, $metadata['datasets'] ?? []))); |
| 615 | |
| 616 | $missingKeys = array_values(array_diff($expectedKeys, $metadataKeys)); |
| 617 | |
| 618 | if ($missingKeys) { |
| 619 | throw new RuntimeException( |
| 620 | 'The Amelia archive is incomplete. Missing datasets: ' . implode(', ', $missingKeys) |
| 621 | ); |
| 622 | } |
| 623 | } |
| 624 | |
| 625 | private function getExpectedImportDatasetKeys(): array |
| 626 | { |
| 627 | $keys = array_map(function ($dataset) { |
| 628 | return $dataset['key']; |
| 629 | }, $this->getDatasetDefinitions()); |
| 630 | |
| 631 | $keys[] = 'settings'; |
| 632 | |
| 633 | return array_values(array_unique($keys)); |
| 634 | } |
| 635 | |
| 636 | private function importSettings(array $settings): void |
| 637 | { |
| 638 | $currentSettings = $this->settingsService->getAllSettingsCategorized(); |
| 639 | $mergedSettings = array_replace($currentSettings ?: [], $settings); |
| 640 | |
| 641 | if ($currentSettings) { |
| 642 | $mergedSettings['activation'] = $currentSettings['activation'] ?? []; |
| 643 | $mergedSettings['wordpress'] = $currentSettings['wordpress'] ?? []; |
| 644 | } |
| 645 | |
| 646 | $this->settingsService->setAllSettings($this->sanitizeSettingsArray($mergedSettings)); |
| 647 | } |
| 648 | |
| 649 | private function importDatasetRows(array $dataset, array $rows, array &$job): void |
| 650 | { |
| 651 | if (!$rows) { |
| 652 | return; |
| 653 | } |
| 654 | |
| 655 | $table = $this->getTableNameFromDataset($dataset); |
| 656 | $columns = $this->getTableColumns($table); |
| 657 | $datasetKey = (string) ($dataset['key'] ?? 'unknown'); |
| 658 | |
| 659 | $filteredRows = []; |
| 660 | $sanitizedRows = []; |
| 661 | |
| 662 | foreach ($rows as $row) { |
| 663 | $sanitizedRow = $this->sanitizeDatasetRow($row); |
| 664 | $filteredRow = $this->filterRowForTable($sanitizedRow, $columns); |
| 665 | |
| 666 | if (!$filteredRow) { |
| 667 | $msg = 'Unable to insert an empty row during Amelia import.'; |
| 668 | $job['summary']['skipped']++; |
| 669 | $this->appendImportError($job, sprintf('Dataset %s: %s', $datasetKey, $msg)); |
| 670 | |
| 671 | continue; |
| 672 | } |
| 673 | |
| 674 | if (($dataset['type'] ?? null) === 'users' && !empty($filteredRow['externalId'])) { |
| 675 | $this->createWordPressUser($dataset, $filteredRow, $sanitizedRow, $datasetKey, $job); |
| 676 | } |
| 677 | |
| 678 | $filteredRows[] = $filteredRow; |
| 679 | $sanitizedRows[] = $sanitizedRow; |
| 680 | } |
| 681 | |
| 682 | if (!$filteredRows) { |
| 683 | return; |
| 684 | } |
| 685 | |
| 686 | $rowCount = count($filteredRows); |
| 687 | |
| 688 | for ($i = 0; $i < $rowCount; $i += self::IMPORT_INSERT_ROWS) { |
| 689 | $filteredSlice = array_slice($filteredRows, $i, self::IMPORT_INSERT_ROWS); |
| 690 | $sanitizedSlice = array_slice($sanitizedRows, $i, self::IMPORT_INSERT_ROWS); |
| 691 | |
| 692 | try { |
| 693 | $this->insertRows($table, $filteredSlice); |
| 694 | $job['summary']['inserted'] += count($filteredSlice); |
| 695 | } catch (\Throwable $e) { |
| 696 | $this->appendImportError( |
| 697 | $job, |
| 698 | sprintf('Dataset %s: Bulk insert failed (%s), retrying individually.', $datasetKey, $e->getMessage()) |
| 699 | ); |
| 700 | foreach ($sanitizedSlice as $sanitizedRow) { |
| 701 | try { |
| 702 | $this->insertRow($table, $sanitizedRow); |
| 703 | $job['summary']['inserted']++; |
| 704 | } catch (\Throwable $rowException) { |
| 705 | $msg = $rowException->getMessage(); |
| 706 | $job['summary']['skipped']++; |
| 707 | $this->appendImportError($job, sprintf('Dataset %s: %s', $datasetKey, $msg)); |
| 708 | } |
| 709 | } |
| 710 | } |
| 711 | } |
| 712 | } |
| 713 | |
| 714 | /** |
| 715 | * User rows exported from another site carry a WordPress user id in {@see UsersTable::externalId} |
| 716 | * that does not exist here. When that column is present and has a value, create (or reuse) a WP |
| 717 | * account for this site and store its id in the row so the INSERT already links Amelia ↔ WP. |
| 718 | */ |
| 719 | private function createWordPressUser( |
| 720 | array $dataset, |
| 721 | array &$filteredRow, |
| 722 | array &$sanitizedRow, |
| 723 | string $datasetKey, |
| 724 | array &$job |
| 725 | ): void { |
| 726 | $email = $filteredRow['email'] ?? ''; |
| 727 | if ($email === '') { |
| 728 | $filteredRow['externalId'] = null; |
| 729 | $sanitizedRow['externalId'] = null; |
| 730 | $this->appendImportError( |
| 731 | $job, |
| 732 | sprintf('Dataset %s: Skipped WordPress link (no email) for Amelia user id %s.', $datasetKey, $filteredRow['id'] ?? '?') |
| 733 | ); |
| 734 | |
| 735 | return; |
| 736 | } |
| 737 | |
| 738 | $userType = $dataset['userType'] ?? ''; |
| 739 | $role = $userType !== '' ? 'wpamelia-' . $userType : null; |
| 740 | |
| 741 | /** @var CreateWPUser $createWpUser */ |
| 742 | $createWpUser = $this->container->get('user.create.wp.user'); |
| 743 | $wpUserId = $createWpUser->create( |
| 744 | $email, |
| 745 | (string) ($filteredRow['firstName'] ?? ''), |
| 746 | (string) ($filteredRow['lastName'] ?? ''), |
| 747 | $role |
| 748 | ); |
| 749 | |
| 750 | if (!$wpUserId) { |
| 751 | $filteredRow['externalId'] = null; |
| 752 | $sanitizedRow['externalId'] = null; |
| 753 | $this->appendImportError( |
| 754 | $job, |
| 755 | sprintf('Dataset %s: WordPress user could not be created for %s; Amelia user imported without link.', $datasetKey, $email) |
| 756 | ); |
| 757 | |
| 758 | return; |
| 759 | } |
| 760 | |
| 761 | $filteredRow['externalId'] = $wpUserId; |
| 762 | $sanitizedRow['externalId'] = $wpUserId; |
| 763 | } |
| 764 | |
| 765 | private function insertRow(string $table, array $row): int |
| 766 | { |
| 767 | $columns = $this->getTableColumns($table); |
| 768 | $filteredRow = $this->filterRowForTable($row, $columns); |
| 769 | |
| 770 | if (!$filteredRow) { |
| 771 | throw new RuntimeException('Unable to insert an empty row during Amelia import.'); |
| 772 | } |
| 773 | |
| 774 | $this->insertRows($table, [$filteredRow]); |
| 775 | |
| 776 | return (int) $this->connection->lastInsertId(); |
| 777 | } |
| 778 | |
| 779 | /** |
| 780 | * @param array<int, array<string, mixed>> $filteredRows |
| 781 | */ |
| 782 | private function insertRows(string $table, array $filteredRows): void |
| 783 | { |
| 784 | if (!$filteredRows) { |
| 785 | return; |
| 786 | } |
| 787 | |
| 788 | $insertColumns = $this->getTableColumns($table); |
| 789 | |
| 790 | if (!$insertColumns) { |
| 791 | throw new RuntimeException('Unable to insert an empty row during Amelia import.'); |
| 792 | } |
| 793 | |
| 794 | $quotedColumns = array_map(function ($column) { |
| 795 | return "`{$column}`"; |
| 796 | }, $insertColumns); |
| 797 | |
| 798 | $valueGroups = []; |
| 799 | $parameters = []; |
| 800 | $placeholderIndex = 0; |
| 801 | |
| 802 | foreach ($filteredRows as $filteredRow) { |
| 803 | $placeholders = []; |
| 804 | |
| 805 | foreach ($insertColumns as $column) { |
| 806 | $placeholder = ':p' . $placeholderIndex++; |
| 807 | $placeholders[] = $placeholder; |
| 808 | $parameters[$placeholder] = array_key_exists($column, $filteredRow) |
| 809 | ? $filteredRow[$column] |
| 810 | : null; |
| 811 | } |
| 812 | |
| 813 | $valueGroups[] = '(' . implode(', ', $placeholders) . ')'; |
| 814 | } |
| 815 | |
| 816 | $sql = sprintf( |
| 817 | 'INSERT INTO `%s` (%s) VALUES %s', |
| 818 | $table, |
| 819 | implode(', ', $quotedColumns), |
| 820 | implode(', ', $valueGroups) |
| 821 | ); |
| 822 | |
| 823 | $statement = $this->connection->prepare($sql); |
| 824 | $statement->execute($parameters); |
| 825 | } |
| 826 | |
| 827 | private function filterRowForTable(array $row, array $columns): array |
| 828 | { |
| 829 | $filteredRow = []; |
| 830 | |
| 831 | foreach ($row as $column => $value) { |
| 832 | if (!in_array($column, $columns, true)) { |
| 833 | continue; |
| 834 | } |
| 835 | |
| 836 | $filteredRow[$column] = $value; |
| 837 | } |
| 838 | |
| 839 | return $filteredRow; |
| 840 | } |
| 841 | |
| 842 | private function getTableColumns(string $table): array |
| 843 | { |
| 844 | if (isset($this->columnCache[$table])) { |
| 845 | return $this->columnCache[$table]; |
| 846 | } |
| 847 | |
| 848 | $statement = $this->connection->query("SHOW COLUMNS FROM `{$table}`"); |
| 849 | $columns = []; |
| 850 | |
| 851 | foreach ($statement->fetchAll() as $column) { |
| 852 | $columns[] = $column['Field']; |
| 853 | } |
| 854 | |
| 855 | $this->columnCache[$table] = $columns; |
| 856 | |
| 857 | return $columns; |
| 858 | } |
| 859 | |
| 860 | private function getTableNameFromDataset(array $dataset): string |
| 861 | { |
| 862 | $tableClass = $dataset['tableClass'] ?? null; |
| 863 | |
| 864 | if (!$tableClass || !method_exists($tableClass, 'getTableName')) { |
| 865 | throw new RuntimeException('The Amelia import/export dataset configuration is invalid.'); |
| 866 | } |
| 867 | |
| 868 | return $tableClass::getTableName(); |
| 869 | } |
| 870 | |
| 871 | private function readJsonData(string $path): array |
| 872 | { |
| 873 | $data = $this->readJsonFile($path); |
| 874 | |
| 875 | return $data['data'] ?? []; |
| 876 | } |
| 877 | |
| 878 | private function readJsonFile(string $path): array |
| 879 | { |
| 880 | $contents = file_get_contents($path); |
| 881 | |
| 882 | if ($contents === false) { |
| 883 | throw new RuntimeException('One of the Amelia import files could not be read.'); |
| 884 | } |
| 885 | |
| 886 | $decoded = json_decode($contents, true); |
| 887 | |
| 888 | if (json_last_error() !== JSON_ERROR_NONE) { |
| 889 | throw new RuntimeException('One of the Amelia import files contains invalid JSON.'); |
| 890 | } |
| 891 | |
| 892 | return is_array($decoded) ? $decoded : []; |
| 893 | } |
| 894 | |
| 895 | private function formatJobStatus(array $job): array |
| 896 | { |
| 897 | $currentStep = $job['steps'][$job['currentStep']] ?? null; |
| 898 | $percentage = (int) min(100, round(($job['processedRows'] / max(1, $job['totalRows'])) * 100)); |
| 899 | $steps = array_map(function ($step) { |
| 900 | return [ |
| 901 | 'key' => $step['key'], |
| 902 | 'processed' => (int) ($step['processed'] ?? 0), |
| 903 | 'records' => (int) ($step['records'] ?? 0), |
| 904 | ]; |
| 905 | }, $job['steps'] ?? []); |
| 906 | |
| 907 | return [ |
| 908 | 'jobId' => $job['id'], |
| 909 | 'status' => $job['status'], |
| 910 | 'percentage' => $percentage, |
| 911 | 'processedRows' => $job['processedRows'], |
| 912 | 'totalRows' => $job['totalRows'], |
| 913 | 'currentDataset' => $currentStep['key'] ?? null, |
| 914 | 'currentDatasetProcessed' => (int) ($currentStep['processed'] ?? 0), |
| 915 | 'currentDatasetTotal' => (int) ($currentStep['records'] ?? 0), |
| 916 | 'steps' => $steps, |
| 917 | 'summary' => $job['summary'], |
| 918 | 'errors' => $job['errors'] ?? [], |
| 919 | 'updatedAt' => $job['updatedAt'], |
| 920 | ]; |
| 921 | } |
| 922 | |
| 923 | private function appendImportError(array &$job, string $message): void |
| 924 | { |
| 925 | if (!isset($job['errors']) || !is_array($job['errors'])) { |
| 926 | $job['errors'] = []; |
| 927 | } |
| 928 | |
| 929 | $job['errors'][] = $message; |
| 930 | } |
| 931 | |
| 932 | private function getJob(string $jobId): ?array |
| 933 | { |
| 934 | $job = get_option($this->getJobOptionName($jobId)); |
| 935 | |
| 936 | return is_array($job) ? $job : null; |
| 937 | } |
| 938 | |
| 939 | private function saveJob(array $job): void |
| 940 | { |
| 941 | update_option($this->getJobOptionName($job['id']), $job, false); |
| 942 | } |
| 943 | |
| 944 | private function getJobOptionName(string $jobId): string |
| 945 | { |
| 946 | return self::JOB_OPTION_PREFIX . $jobId; |
| 947 | } |
| 948 | |
| 949 | private function createWorkingDirectory(string $jobId): string |
| 950 | { |
| 951 | $uploads = wp_upload_dir(); |
| 952 | $baseDirectory = trailingslashit($uploads['basedir']) . 'amelia/data-transfer'; |
| 953 | $workingDirectory = $baseDirectory . '/' . sanitize_file_name($jobId); |
| 954 | |
| 955 | wp_mkdir_p($workingDirectory); |
| 956 | |
| 957 | return $workingDirectory; |
| 958 | } |
| 959 | |
| 960 | private function truncateAllTables(): void |
| 961 | { |
| 962 | $datasets = $this->getDatasetDefinitions(); |
| 963 | $tablesToTruncate = []; |
| 964 | |
| 965 | foreach ($datasets as $dataset) { |
| 966 | if (isset($dataset['tableClass'])) { |
| 967 | $tableName = call_user_func([$dataset['tableClass'], 'getTableName']); |
| 968 | $tablesToTruncate[] = $tableName; |
| 969 | } elseif (($dataset['type'] ?? null) === 'users') { |
| 970 | $tablesToTruncate[] = UsersTable::getTableName(); |
| 971 | } |
| 972 | } |
| 973 | |
| 974 | $tablesToTruncate[] = GalleriesTable::getTableName(); |
| 975 | |
| 976 | $tablesToTruncate = array_values(array_unique($tablesToTruncate)); |
| 977 | |
| 978 | try { |
| 979 | foreach (array_reverse($tablesToTruncate) as $table) { |
| 980 | $this->connection->query("TRUNCATE TABLE `{$table}`"); |
| 981 | } |
| 982 | } catch (\Exception $e) { |
| 983 | throw new RuntimeException('Unable to truncate Amelia tables before import: ' . $e->getMessage()); |
| 984 | } |
| 985 | } |
| 986 | |
| 987 | private function cleanupJobFiles(array $job, bool $deleteJobOption = true): void |
| 988 | { |
| 989 | if (!empty($job['archivePath']) && file_exists($job['archivePath'])) { |
| 990 | @unlink($job['archivePath']); |
| 991 | } |
| 992 | |
| 993 | if (!empty($job['extractDirectory']) && is_dir($job['extractDirectory'])) { |
| 994 | $this->deleteDirectory(dirname($job['extractDirectory'])); |
| 995 | } |
| 996 | |
| 997 | if ($deleteJobOption) { |
| 998 | delete_option($this->getJobOptionName($job['id'])); |
| 999 | } |
| 1000 | } |
| 1001 | |
| 1002 | private function deleteDirectory(string $directory): void |
| 1003 | { |
| 1004 | if (!is_dir($directory)) { |
| 1005 | return; |
| 1006 | } |
| 1007 | |
| 1008 | $items = scandir($directory); |
| 1009 | |
| 1010 | if (!is_array($items)) { |
| 1011 | return; |
| 1012 | } |
| 1013 | |
| 1014 | foreach ($items as $item) { |
| 1015 | if (in_array($item, ['.', '..'], true)) { |
| 1016 | continue; |
| 1017 | } |
| 1018 | |
| 1019 | $path = $directory . '/' . $item; |
| 1020 | |
| 1021 | if (is_dir($path)) { |
| 1022 | $this->deleteDirectory($path); |
| 1023 | } else { |
| 1024 | @unlink($path); |
| 1025 | } |
| 1026 | } |
| 1027 | |
| 1028 | @rmdir($directory); |
| 1029 | } |
| 1030 | |
| 1031 | private function assertZipArchiveAvailable(): void |
| 1032 | { |
| 1033 | if (!class_exists(ZipArchive::class)) { |
| 1034 | throw new RuntimeException('ZipArchive is not available on this server.'); |
| 1035 | } |
| 1036 | } |
| 1037 | |
| 1038 | private function hasAmeliaFileExtension(string $fileName): bool |
| 1039 | { |
| 1040 | return substr(strtolower($fileName), -11) === '.ameliafile'; |
| 1041 | } |
| 1042 | |
| 1043 | private function isImageColumn(string $column): bool |
| 1044 | { |
| 1045 | return (bool) preg_match('/(image|gallery|picture|thumb|fullPath)/i', $column); |
| 1046 | } |
| 1047 | } |
| 1048 |