PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Migrations / ItineraryMigration.php

ItineraryMigration.php in Yatra – Travel Booking & Tour Operator Software 3.0.13, at app/Migrations/ItineraryMigration.php

530 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yatra\Migration;
4
5 use Yatra\Database\Tables\TripItineraryDaysTable;
6 use Yatra\Database\Tables\TripItineraryDayEntryTable;
7 use Yatra\Utils\Logger;
8
9 /**
10 * Itinerary Migration - Migrate trip itineraries from old Yatra versions
11 *
12 * This class migrates itinerary data from old tour posts to the new itinerary structure.
13 * It handles various old itinerary data formats and converts them to the new
14 * yatra_trip_itinerary_days and yatra_trip_itinerary_entries tables.
15 */
16 class ItineraryMigration extends BaseMigration
17 {
18 public function __construct(MigrationProgress $service)
19 {
20 parent::__construct($service);
21 }
22
23 /**
24 * Run the itinerary migration
25 */
26 public function run(): array
27 {
28 // CRITICAL DEBUG: Log immediately to see if this method is called
29 $migrated = 0;
30 $skipped = 0;
31 $failed = 0;
32
33 try {
34 // Get all old tours that have been migrated to new trips
35 $oldToursWithItinerary = $this->getOldToursWithItinerary();
36 $total = count($oldToursWithItinerary);
37
38 Logger::info("Itinerary Migration: Starting migration", [
39 'total_tours_with_itinerary' => $total,
40 'force_migration' => $this->service->isForceMigration()
41 ]);
42
43 foreach ($oldToursWithItinerary as $oldTour) {
44 try {
45 // Get the new trip ID from migration mapping
46 $newTripId = $this->getMigratedTripId($oldTour->ID);
47
48 if (!$newTripId) {
49 Logger::info("Tour not migrated yet, skipping itinerary", [
50 'old_tour_id' => $oldTour->ID,
51 'tour_title' => $oldTour->post_title
52 ]);
53 $skipped++;
54 continue;
55 }
56
57 // Check if itinerary already exists for this trip (skip for normal migration)
58 if (!$this->service->isForceMigration() && $this->hasItinerary($newTripId)) {
59 Logger::info("Itinerary already exists, skipping", [
60 'trip_id' => $newTripId,
61 'old_tour_id' => $oldTour->ID
62 ]);
63 $skipped++;
64 continue;
65 }
66
67 // During force migration, we delete existing itinerary and recreate
68 if ($this->service->isForceMigration() && $this->hasItinerary($newTripId)) {
69 Logger::info("Force migration: deleting existing itinerary", [
70 'trip_id' => $newTripId,
71 'old_tour_id' => $oldTour->ID
72 ]);
73 $this->deleteTripItinerary($newTripId);
74 }
75
76 // Extract and migrate itinerary data
77 $itineraryData = $this->extractItineraryData($oldTour->ID);
78
79 if (empty($itineraryData)) {
80 Logger::info("No itinerary data found for tour", [
81 'old_tour_id' => $oldTour->ID,
82 'tour_title' => $oldTour->post_title
83 ]);
84 $skipped++;
85 continue;
86 }
87
88 // Create itinerary in new structure
89 $result = $this->createItinerary($newTripId, $itineraryData, $oldTour->ID);
90
91 if ($result) {
92 $migrated++;
93 Logger::info("Itinerary migrated successfully", [
94 'trip_id' => $newTripId,
95 'old_tour_id' => $oldTour->ID,
96 'tour_title' => $oldTour->post_title,
97 'days_count' => count($itineraryData)
98 ]);
99 } else {
100 $failed++;
101 Logger::error("Failed to migrate itinerary", [
102 'trip_id' => $newTripId,
103 'old_tour_id' => $oldTour->ID
104 ]);
105 }
106
107 } catch (\Exception $e) {
108 $failed++;
109 Logger::error("Exception during itinerary migration", [
110 'old_tour_id' => $oldTour->ID,
111 'error' => $e->getMessage(),
112 'trace' => $e->getTraceAsString()
113 ]);
114 }
115 }
116
117 Logger::info("Itinerary Migration: Completed", [
118 'migrated' => $migrated,
119 'skipped' => $skipped,
120 'failed' => $failed,
121 'total' => $total,
122 'force_migration' => $this->service->isForceMigration()
123 ]);
124
125 return compact('migrated', 'skipped', 'failed', 'total');
126
127 } catch (\Exception $e) {
128 Logger::error("Itinerary Migration failed", [
129 'error' => $e->getMessage(),
130 'trace' => $e->getTraceAsString()
131 ]);
132
133 return [
134 'migrated' => $migrated,
135 'skipped' => $skipped,
136 'failed' => $failed + 1,
137 'total' => $total ?? 0
138 ];
139 }
140 }
141
142 /**
143 * Get old tours that might have itinerary data.
144 *
145 * Uses an efficient SQL query with EXISTS to find only tours that
146 * actually have itinerary-related meta keys, instead of loading
147 * all meta for every tour and filtering in PHP.
148 */
149 private function getOldToursWithItinerary(): array
150 {
151 // Known itinerary meta keys from old Yatra versions
152 $itineraryKeys = [
153 'itinerary_repeator',
154 'itinerary_label',
155 'yatra_tour_itinerary',
156 'yatra_tour_meta_itinerary',
157 'yatra_itinerary',
158 'tour_itinerary',
159 'yatra_tour_days',
160 'yatra_tour_meta_days',
161 'yatra_days',
162 'tour_days',
163 'yatra_tour_schedule',
164 'yatra_tour_meta_schedule',
165 'yatra_schedule',
166 'tour_schedule',
167 ];
168
169 $placeholders = implode(',', array_fill(0, count($itineraryKeys), '%s'));
170
171 // Single efficient SQL query: only return tours with itinerary meta
172 $sql = $this->wpdb->prepare(
173 "SELECT DISTINCT p.*
174 FROM {$this->wpdb->posts} p
175 INNER JOIN {$this->wpdb->postmeta} pm ON p.ID = pm.post_id
176 WHERE p.post_type = 'tour'
177 AND p.post_status IN ('publish', 'draft', 'pending', 'private')
178 AND pm.meta_key IN ({$placeholders})
179 AND pm.meta_value != ''
180 AND pm.meta_value IS NOT NULL",
181 ...$itineraryKeys
182 );
183
184 $tours = $this->wpdb->get_results($sql);
185
186 Logger::info("Itinerary Migration: Found tours with itinerary meta", [
187 'total_tours_with_itinerary' => count($tours),
188 ]);
189
190 return $tours;
191 }
192
193 /**
194 * Extract itinerary data from old tour meta
195 */
196 private function extractItineraryData(int $oldTourId): array
197 {
198 $meta = $this->getPostMeta($oldTourId);
199 $itineraryData = [];
200
201 // Try different possible meta keys for itinerary data
202 $itineraryKeys = [
203 'itinerary_repeator', // This is the actual key found in the database
204 'itinerary_label',
205 'yatra_tour_itinerary',
206 'yatra_tour_meta_itinerary',
207 'yatra_itinerary',
208 'tour_itinerary'
209 ];
210
211 foreach ($itineraryKeys as $key) {
212 if (!empty($meta[$key])) {
213 $itineraryData = $this->parseItineraryData($meta[$key]);
214 if (!empty($itineraryData)) {
215 break;
216 }
217 }
218 }
219
220 // If no structured data found, try to parse from content
221 if (empty($itineraryData)) {
222 $tour = get_post($oldTourId);
223 if ($tour) {
224 $itineraryData = $this->parseItineraryFromContent($tour->post_content);
225 }
226 }
227
228 return $itineraryData;
229 }
230
231 /**
232 * Parse itinerary data from meta value
233 */
234 private function parseItineraryData($itineraryData): array
235 {
236 $parsed = [];
237
238 // Handle different data formats
239 if (is_string($itineraryData)) {
240 // Itinerary meta is only ever expected to hold scalars/arrays. Forbid object
241 // instantiation so a crafted serialized payload in legacy post meta cannot trigger
242 // PHP object-injection / __destruct gadget chains during migration.
243 $unserialized = false;
244 if ($itineraryData !== '' && (str_starts_with($itineraryData, 'a:') || str_starts_with($itineraryData, 's:'))) {
245 set_error_handler(static function (): bool { return true; }); // suppress unserialize notices
246 try {
247 $unserialized = unserialize($itineraryData, ['allowed_classes' => false]);
248 } finally {
249 restore_error_handler();
250 }
251 }
252
253 if ($unserialized !== false) {
254 $itineraryData = $unserialized;
255 } else {
256 // Try to decode JSON. Don't suppress with @ — log decode errors so silent data loss is visible.
257 $decoded = json_decode($itineraryData, true);
258 if (json_last_error() === JSON_ERROR_NONE && $decoded !== null) {
259 $itineraryData = $decoded;
260 }
261 }
262 }
263
264 // Handle the specific itinerary_repeator structure
265 if (is_array($itineraryData) && isset($itineraryData['itinerary_heading'])) {
266 $headings = $itineraryData['itinerary_heading'] ?? [];
267 $titles = $itineraryData['itinerary_title'] ?? [];
268 $details = $itineraryData['itinerary_details'] ?? [];
269
270 $maxDays = max(count($headings), count($titles), count($details));
271
272 for ($i = 0; $i < $maxDays; $i++) {
273 $dayTitle = !empty($titles[$i]) ? $titles[$i] : $headings[$i] ?? '';
274 $dayDescription = $details[$i] ?? '';
275
276 if (!empty($dayTitle) || !empty($dayDescription)) {
277 $parsed[] = [
278 'day_number' => $i + 1,
279 'title' => sanitize_text_field($dayTitle),
280 'description' => wp_kses_post($dayDescription),
281 'entries' => [] // No separate entries in this format
282 ];
283 }
284 }
285 }
286 // Handle generic array format
287 elseif (is_array($itineraryData)) {
288 foreach ($itineraryData as $dayData) {
289 if (is_array($dayData) && !empty($dayData['title'])) {
290 $parsed[] = [
291 'day_number' => intval($dayData['day'] ?? $dayData['day_number'] ?? (count($parsed) + 1)),
292 'title' => sanitize_text_field($dayData['title'] ?? $dayData['day_title'] ?? ''),
293 'description' => wp_kses_post($dayData['description'] ?? $dayData['content'] ?? ''),
294 'entries' => $this->parseDayEntries($dayData['entries'] ?? $dayData['activities'] ?? [])
295 ];
296 }
297 }
298 }
299
300 return $parsed;
301 }
302
303 /**
304 * Parse day entries/activities
305 */
306 private function parseDayEntries($entries): array
307 {
308 $parsedEntries = [];
309
310 if (!is_array($entries)) {
311 return $parsedEntries;
312 }
313
314 foreach ($entries as $entry) {
315 if (is_array($entry) && !empty($entry['title'])) {
316 $parsedEntries[] = [
317 'title' => sanitize_text_field($entry['title'] ?? $entry['activity'] ?? ''),
318 'description' => wp_kses_post($entry['description'] ?? $entry['content'] ?? ''),
319 'time' => sanitize_text_field($entry['time'] ?? $entry['time_of_day'] ?? ''),
320 'start_time' => sanitize_text_field($entry['start_time'] ?? ''),
321 'end_time' => sanitize_text_field($entry['end_time'] ?? ''),
322 'location' => sanitize_text_field($entry['location'] ?? ''),
323 'order' => intval($entry['order'] ?? count($parsedEntries)),
324 'item_type_id' => null, // Will be set to default activity type
325 'item_id' => null
326 ];
327 }
328 }
329
330 return $parsedEntries;
331 }
332
333 /**
334 * Parse itinerary from tour content (fallback method)
335 */
336 private function parseItineraryFromContent(string $content): array
337 {
338 $itinerary = [];
339
340 // Simple regex to extract day-based content
341 // This is a basic implementation - can be enhanced based on actual content structure
342 preg_match_all('/(?:Day\s*(\d+)|(\d+)\.\s*Day)[\s:]*([^\n]*(?:\n(?!Day|\d+\.)[^\n]*)*)/i', $content, $matches, PREG_SET_ORDER);
343
344 foreach ($matches as $match) {
345 $dayNumber = intval($match[1] ?: $match[2]);
346 $dayContent = trim($match[3] ?? '');
347
348 if ($dayNumber > 0 && !empty($dayContent)) {
349 // Extract title from first line
350 $lines = explode("\n", $dayContent);
351 $title = trim($lines[0] ?? "Day $dayNumber");
352 $description = trim(implode("\n", array_slice($lines, 1)));
353
354 $itinerary[] = [
355 'day_number' => $dayNumber,
356 'title' => sanitize_text_field($title),
357 'description' => wp_kses_post($description),
358 'entries' => []
359 ];
360 }
361 }
362
363 return $itinerary;
364 }
365
366 /**
367 * Clean day title and description by removing "Day {index} -" pattern
368 */
369 private function cleanDayData(string $title, string $description): array
370 {
371 // Decode entities so non-breaking spaces or dashed become native characters
372 $title = html_entity_decode(trim($title), ENT_QUOTES, 'UTF-8');
373 $description = html_entity_decode(trim($description), ENT_QUOTES, 'UTF-8');
374
375 // Remove "Day X -", "Day X :", "Day {index} -" pattern from title and description
376 // Matches literal "{index}" as well, just in case the old JS templates saved it to the DB.
377 $pattern = '/^Day[\s\xA0]*(\d+|\{index\})[\s\xA0]*[-:–—]*[\s\xA0]*/iu';
378
379 $cleanTitle = preg_replace($pattern, '', $title);
380 $cleanDescription = preg_replace($pattern, '', $description);
381
382 return [
383 'title' => trim($cleanTitle),
384 'description' => trim($cleanDescription)
385 ];
386 }
387
388 /**
389 * Create itinerary in new structure
390 */
391 private function createItinerary(int $tripId, array $itineraryData, int $oldTourId): bool
392 {
393 try {
394 $tableDays = TripItineraryDaysTable::getTableName();
395 $tableEntries = TripItineraryDayEntryTable::getTableName();
396
397 // Get current user ID for tracking
398 $currentUserId = get_current_user_id();
399 if (!$currentUserId) {
400 $currentUserId = 1; // Fallback to admin
401 }
402
403 foreach ($itineraryData as $dayData) {
404 // Clean day title and description
405 $cleanedData = $this->cleanDayData($dayData['title'], $dayData['description']);
406
407 // Create day
408 $dayResult = $this->wpdb->insert(
409 $tableDays,
410 [
411 'trip_id' => $tripId,
412 'day_number' => $dayData['day_number'],
413 'title' => $cleanedData['title'],
414 'description' => $cleanedData['description'],
415 'order' => $dayData['day_number'] - 1,
416 'created_at' => current_time('mysql'),
417 'updated_at' => current_time('mysql')
418 ],
419 ['%d', '%d', '%s', '%s', '%d', '%s', '%s']
420 );
421
422 if (!$dayResult) {
423 Logger::error("Failed to create itinerary day", [
424 'trip_id' => $tripId,
425 'day_number' => $dayData['day_number'],
426 'wpdb_error' => $this->wpdb->last_error
427 ]);
428 continue;
429 }
430
431 $dayId = $this->wpdb->insert_id;
432
433 // Create entries for this day
434 foreach ($dayData['entries'] as $entryData) {
435 $cleanedEntry = $this->cleanDayData($entryData['title'], $entryData['description']);
436 $entryResult = $this->wpdb->insert(
437 $tableEntries,
438 [
439 'trip_id' => $tripId,
440 'day_id' => $dayId,
441 'title' => $cleanedEntry['title'],
442 'description' => $cleanedEntry['description'],
443 'time' => $entryData['time'],
444 'start_time' => $entryData['start_time'],
445 'end_time' => $entryData['end_time'],
446 'location' => $entryData['location'],
447 'item_type_id' => $entryData['item_type_id'],
448 'item_id' => $entryData['item_id'],
449 'status' => 'publish',
450 'order' => $entryData['order'],
451 'created_at' => current_time('mysql'),
452 'updated_at' => current_time('mysql')
453 ],
454 ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%d', '%s', '%s']
455 );
456
457 if (!$entryResult) {
458 Logger::error("Failed to create itinerary entry", [
459 'trip_id' => $tripId,
460 'day_id' => $dayId,
461 'entry_title' => $entryData['title'],
462 'wpdb_error' => $this->wpdb->last_error
463 ]);
464 }
465 }
466 }
467
468 Logger::info("Itinerary created successfully", [
469 'trip_id' => $tripId,
470 'old_tour_id' => $oldTourId,
471 'days_created' => count($itineraryData)
472 ]);
473
474 return true;
475
476 } catch (\Exception $e) {
477 Logger::error("Exception creating itinerary", [
478 'trip_id' => $tripId,
479 'old_tour_id' => $oldTourId,
480 'error' => $e->getMessage()
481 ]);
482 return false;
483 }
484 }
485
486 /**
487 * Check if trip already has itinerary
488 */
489 private function hasItinerary(int $tripId): bool
490 {
491 $tableDays = TripItineraryDaysTable::getTableName();
492
493 $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
494 "SELECT COUNT(*) FROM {$tableDays} WHERE trip_id = %d",
495 $tripId
496 ));
497
498 return $count > 0;
499 }
500
501 /**
502 * Delete existing itinerary for a trip
503 */
504 private function deleteTripItinerary(int $tripId): void
505 {
506 $tableDays = TripItineraryDaysTable::getTableName();
507 $tableEntries = TripItineraryDayEntryTable::getTableName();
508
509 // Delete entries first (foreign key constraint)
510 $this->wpdb->delete($tableEntries, ['trip_id' => $tripId], ['%d']);
511
512 // Delete days
513 $this->wpdb->delete($tableDays, ['trip_id' => $tripId], ['%d']);
514 }
515
516 /**
517 * Get migrated trip ID from old tour ID
518 */
519 protected function getMigratedTripId(int $oldTourId): ?int
520 {
521 $tripId = $this->wpdb->get_var($this->wpdb->prepare(
522 "SELECT meta_value FROM {$this->wpdb->postmeta}
523 WHERE meta_key = '_migrated_to_trip_id' AND post_id = %d",
524 $oldTourId
525 ));
526
527 return $tripId ? (int) $tripId : null;
528 }
529 }
530