PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.6
Booking for Appointments and Events Calendar – Amelia v2.4.6
2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / src / Infrastructure / Services / DataTransfer / DataTransferService.php
ameliabooking / src / Infrastructure / Services / DataTransfer Last commit date
DataTransferService.php 4 weeks ago
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