| @@ -465,8 +465,13 @@ | ||
| 465 | 465 | * @param string $jobId Job ID |
| 466 | 466 | */ |
| 467 | 467 | public static function processImportJob(string $jobId): void |
| 468 | 468 | { |
| 469 | + // Per-job state: without this a second import in the same request would | |
| 470 | + // inherit the first one's "already here" set. | |
| 471 | + self::$importPreExisting = []; | |
| 472 | + self::$importSkippedOldIds = []; | |
| 473 | + | |
| 469 | 474 | $repository = new ExportImportRepository(); |
| 470 | 475 | global $wpdb; |
| 471 | 476 | |
| 472 | 477 | $jobData = self::getJobStatus($jobId); |
| @@ -556,8 +561,30 @@ | ||
| 556 | 561 | } |
| 557 | 562 | |
| 558 | 563 | $dataTypes = self::sortImportDataTypes($dataTypes); |
| 559 | 564 | |
| 565 | + /** | |
| 566 | + * Last chance to create any table the file needs. | |
| 567 | + * | |
| 568 | + * Yatra Pro deliberately does not create its tables on activation — | |
| 569 | + * each module creates its own when it is switched on. That is fine | |
| 570 | + * for a running site and wrong for a restore: a backup taken from a | |
| 571 | + * site that used Email Automation, Dynamic Pricing or Consent Forms | |
| 572 | + * arrives at a fresh install whose tables for those do not exist | |
| 573 | + * yet, and every one of those rows was counted as a failure and | |
| 574 | + * dropped. On the migration this was tested against that silently | |
| 575 | + * lost 2,440 rows — the entire email history, pricing history and | |
| 576 | + * template set. | |
| 577 | + * | |
| 578 | + * Pro listens for this and brings its tables into being so the data | |
| 579 | + * has somewhere to land; whether the operator later switches those | |
| 580 | + * modules on is a separate decision from whether their data survived | |
| 581 | + * the move. | |
| 582 | + * | |
| 583 | + * @param list<string> $dataTypes Data types present in the file. | |
| 584 | + */ | |
| 585 | + do_action('yatra_export_import_prepare_tables', $dataTypes); | |
| 586 | + | |
| 560 | 587 | $mapper = new ExportImportIdMapper(); |
| 561 | 588 | |
| 562 | 589 | $totalRecords = 0; |
| 563 | 590 | foreach ($dataTypes as $dt) { |
| @@ -613,11 +640,12 @@ | ||
| 613 | 640 | $dayTotal = count($payload['days']); |
| 614 | 641 | $entryTotal = count($payload['entries']); |
| 615 | 642 | $importStats['itinerary'] = [ |
| 616 | 643 | 'total' => $dayTotal + $entryTotal, |
| 617 | - 'imported' => 0, | |
| 618 | - 'failed' => 0, | |
| 619 | - ]; | |
| 644 | + 'imported' => 0, | |
| 645 | + 'failed' => 0, | |
| 646 | + 'skipped' => 0, | |
| 647 | + ]; | |
| 620 | 648 | |
| 621 | 649 | foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) { |
| 622 | 650 | foreach ($batch as $record) { |
| 623 | 651 | $record = (array) $record; |
| @@ -623,8 +651,21 @@ | ||
| 623 | 651 | $record = (array) $record; |
| 624 | 652 | $oldDayId = (int) ($record['id'] ?? 0); |
| 625 | 653 | unset($record['id']); |
| 626 | 654 | try { |
| 655 | + // Itinerary has its own import path, so it needs the | |
| 656 | + // same recognition the main loop does: a trip already | |
| 657 | + // here already has its days, and re-inserting them is | |
| 658 | + // what made every one of these fail on a second run. | |
| 659 | + $oldTripIdForDay = (int) ($record['trip_id'] ?? 0); | |
| 660 | + if ($oldTripIdForDay > 0 && !empty(self::$importSkippedOldIds['trips'][$oldTripIdForDay])) { | |
| 661 | + $importStats['itinerary']['skipped'] = ($importStats['itinerary']['skipped'] ?? 0) + 1; | |
| 662 | + if ($oldDayId > 0) { | |
| 663 | + self::$importSkippedOldIds['itinerary_days'][$oldDayId] = true; | |
| 664 | + } | |
| 665 | + continue; | |
| 666 | + } | |
| 667 | + | |
| 627 | 668 | if (isset($record['trip_id'])) { |
| 628 | 669 | $mappedTrip = $mapper->map('trips', $record['trip_id']); |
| 629 | 670 | $record['trip_id'] = $mappedTrip; |
| 630 | 671 | } |
| @@ -669,8 +710,14 @@ | ||
| 669 | 710 | foreach ($batch as $record) { |
| 670 | 711 | $record = (array) $record; |
| 671 | 712 | unset($record['id']); |
| 672 | 713 | try { |
| 714 | + $oldDayRef = (int) ($record['day_id'] ?? 0); | |
| 715 | + if ($oldDayRef > 0 && !empty(self::$importSkippedOldIds['itinerary_days'][$oldDayRef])) { | |
| 716 | + $importStats['itinerary']['skipped'] = ($importStats['itinerary']['skipped'] ?? 0) + 1; | |
| 717 | + continue; | |
| 718 | + } | |
| 719 | + | |
| 673 | 720 | if (isset($record['day_id'])) { |
| 674 | 721 | $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']); |
| 675 | 722 | } |
| 676 | 723 | if (isset($record['trip_id'])) { |
| @@ -802,8 +849,18 @@ | ||
| 802 | 849 | 'destinations', |
| 803 | 850 | 'activities', |
| 804 | 851 | 'categories', |
| 805 | 852 | 'difficulty_levels', |
| 853 | + // The classifications table holds eight kinds of row and only the | |
| 854 | + // four above were ever exported. Traveller categories, attributes | |
| 855 | + // and itinerary item types are operator-configured data too, and | |
| 856 | + // leaving them out silently dropped them from every backup — and | |
| 857 | + // took the trip links that pointed at them down with it, because | |
| 858 | + // those could no longer resolve on import. | |
| 859 | + 'traveler_categories', | |
| 860 | + 'attributes', | |
| 861 | + 'itinerary_item_types', | |
| 862 | + 'itinerary_items', | |
| 806 | 863 | 'trips', |
| 807 | 864 | 'itinerary', |
| 808 | 865 | ]; |
| 809 | 866 | $mergedKeys = array_keys(self::getMergedTableMap()); |
| @@ -812,8 +869,225 @@ | ||
| 812 | 869 | return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys))); |
| 813 | 870 | } |
| 814 | 871 | |
| 815 | 872 | /** |
| 873 | + * Rows found to be already present during the current import, as | |
| 874 | + * entity => [id => true]. Children of these are skipped: the parent is | |
| 875 | + * already here, so its children are too. | |
| 876 | + * | |
| 877 | + * @var array<string, array<int, bool>> | |
| 878 | + */ | |
| 879 | + private static $importPreExisting = []; | |
| 880 | + | |
| 881 | + /** | |
| 882 | + * Export-side ids skipped during this import, as entity => [oldId => true]. | |
| 883 | + * | |
| 884 | + * Needed for depth: when a booking is recognised, its travellers are skipped | |
| 885 | + * too, and those travellers never get an id mapping — so their meta rows had | |
| 886 | + * nothing to resolve `traveller_id` against and were rejected. Remembering | |
| 887 | + * the skip by export id lets the whole subtree be skipped cleanly. | |
| 888 | + * | |
| 889 | + * @var array<string, array<int, bool>> | |
| 890 | + */ | |
| 891 | + private static $importSkippedOldIds = []; | |
| 892 | + | |
| 893 | + /** | |
| 894 | + * Natural-key columns that are themselves foreign keys, so they hold the | |
| 895 | + * export's id and must be translated to this site's before matching. | |
| 896 | + * | |
| 897 | + * @var array<string, array<string, string>> dataType => [column => entity] | |
| 898 | + */ | |
| 899 | + private const IMPORT_NATURAL_KEY_FKS = [ | |
| 900 | + 'availability' => ['trip_id' => 'trips'], | |
| 901 | + ]; | |
| 902 | + | |
| 903 | + /** | |
| 904 | + * Business identity per data type, used to recognise a row the importer has | |
| 905 | + * already brought in. | |
| 906 | + * | |
| 907 | + * Import inserts every row with a fresh id, so without this a second run | |
| 908 | + * duplicated everything that had no unique constraint (bookings, trip | |
| 909 | + * content, availability) and rejected everything that had one — on a real | |
| 910 | + * site that meant all 69 customers failing because `uk_email` refused them. | |
| 911 | + * Matching on the natural key instead lets an existing row be reused. | |
| 912 | + * | |
| 913 | + * @var array<string, list<string>> | |
| 914 | + */ | |
| 915 | + private const IMPORT_NATURAL_KEYS = [ | |
| 916 | + 'trips' => ['slug'], | |
| 917 | + 'customers' => ['email'], | |
| 918 | + 'bookings' => ['reference'], | |
| 919 | + 'discounts' => ['code'], | |
| 920 | + 'email_templates' => ['template_key'], | |
| 921 | + 'consent_requests' => ['token'], | |
| 922 | + 'availability' => ['trip_id', 'departure_date', 'departure_time'], | |
| 923 | + | |
| 924 | + // The four taxonomies share one table, which already carries a UNIQUE | |
| 925 | + // KEY on (type, slug). Without an identity here the slug was handed to | |
| 926 | + // ensureUniqueSlug() first, which renamed "hiking" to "hiking-i1" and so | |
| 927 | + // re-imported every term as a new one instead of recognising it. | |
| 928 | + 'destinations' => ['type', 'slug'], | |
| 929 | + 'activities' => ['type', 'slug'], | |
| 930 | + 'categories' => ['type', 'slug'], | |
| 931 | + 'difficulty_levels' => ['type', 'slug'], | |
| 932 | + 'traveler_categories' => ['type', 'slug'], | |
| 933 | + 'attributes' => ['type', 'slug'], | |
| 934 | + 'itinerary_item_types' => ['type', 'slug'], | |
| 935 | + 'itinerary_items' => ['type', 'slug'], | |
| 936 | + | |
| 937 | + // Catalogue rows an operator maintains by name. Nothing about them is | |
| 938 | + // unique at the database level, so a re-import simply added a second | |
| 939 | + // "Trip Liability Waiver" beside the first. The name alone would be the | |
| 940 | + // obvious key, but nothing stops a site holding two services called | |
| 941 | + // "Transfer" at different prices, and keying on the name would drop one | |
| 942 | + // of them from a restore. Each key therefore carries enough of the row | |
| 943 | + // to tell two genuinely different ones apart, while an unchanged row | |
| 944 | + // still matches itself exactly. | |
| 945 | + 'additional_service_catalog' => ['name', 'price', 'price_type', 'price_per'], | |
| 946 | + 'consent_forms' => ['name', 'version'], | |
| 947 | + 'dynamic_pricing_rules' => ['name', 'rule_type', 'adjustment_type', 'adjustment_value'], | |
| 948 | + | |
| 949 | + // email_logs deliberately has no natural key. The obvious candidate, | |
| 950 | + // (recipient_email, template_key, sent_at), collides on real data — a | |
| 951 | + // 1,231-row log here held 419 rows that share one with another row, | |
| 952 | + // because a booking can send the same template to the same address | |
| 953 | + // within the same second. Keying on it would silently drop those rows | |
| 954 | + // from a restore, and losing history is worse than repeating it, so a | |
| 955 | + // re-import appends the log again rather than risk discarding it. | |
| 956 | + ]; | |
| 957 | + | |
| 958 | + /** | |
| 959 | + * Owning parent for rows that carry no identity of their own. | |
| 960 | + * | |
| 961 | + * A trip's content, itinerary and departures belong to that trip. When the | |
| 962 | + * trip is recognised as already present its children are already present | |
| 963 | + * too, so re-inserting them is what produced duplicate itineraries and | |
| 964 | + * duplicate departure rows on a second import. | |
| 965 | + * | |
| 966 | + * @var array<string, array{0: string, 1: string}> dataType => [entity, fk column] | |
| 967 | + */ | |
| 968 | + private const IMPORT_PRIMARY_PARENT = [ | |
| 969 | + 'trip_classifications' => [['trips', 'trip_id']], | |
| 970 | + 'trip_content' => [['trips', 'trip_id']], | |
| 971 | + 'trip_revisions' => [['trips', 'trip_id']], | |
| 972 | + 'itinerary' => [['trips', 'trip_id']], | |
| 973 | + 'availability_rules' => [['trips', 'trip_id']], | |
| 974 | + 'departures' => [['trips', 'trip_id']], | |
| 975 | + 'reviews' => [['trips', 'trip_id']], | |
| 976 | + 'enquiries' => [['trips', 'trip_id']], | |
| 977 | + 'pricing_history' => [['trips', 'trip_id']], | |
| 978 | + 'trip_demand_scores' => [['trips', 'trip_id']], | |
| 979 | + 'trip_additional_services' => [['trips', 'trip_id'], ['services', 'service_id']], | |
| 980 | + 'trip_consent_forms' => [['trips', 'trip_id'], ['consent_forms', 'form_id']], | |
| 981 | + // Two parents, and either is enough: a booking_departures row cannot be | |
| 982 | + // new if the departure it points at is already here. This is what left | |
| 983 | + // 240 of them failing — departures are skipped as children of a trip, so | |
| 984 | + // they carry no id mapping and `departure_id` could not resolve. | |
| 985 | + 'booking_departures' => [['bookings', 'booking_id'], ['departures', 'departure_id']], | |
| 986 | + 'booking_additional_services' => [['bookings', 'booking_id'], ['services', 'service_id']], | |
| 987 | + 'travelers' => [['bookings', 'booking_id']], | |
| 988 | + 'payments' => [['bookings', 'booking_id']], | |
| 989 | + 'google_calendar_events' => [['bookings', 'booking_id'], ['departures', 'departure_id']], | |
| 990 | + 'signed_consents' => [['bookings', 'booking_id'], ['consent_forms', 'form_id']], | |
| 991 | + 'traveler_meta' => [['travelers', 'traveller_id']], | |
| 992 | + 'recovery_email_logs' => [['abandoned_bookings', 'abandoned_booking_id']], | |
| 993 | + 'abandoned_bookings' => [['trips', 'trip_id']], | |
| 994 | + 'email_sequence_steps' => [['email_sequences', 'sequence_id']], | |
| 995 | + 'email_queue' => [['email_sequences', 'sequence_id']], | |
| 996 | + ]; | |
| 997 | + | |
| 998 | + /** | |
| 999 | + * Id of the row this site already holds for the record being imported, or | |
| 1000 | + * null when it is genuinely new. | |
| 1001 | + * | |
| 1002 | + * Two ways a row can already be here: it carries a business key that | |
| 1003 | + * matches (a trip slug, a customer email), or it is a child of a parent we | |
| 1004 | + * just recognised, in which case the parent brought its children with it. | |
| 1005 | + * | |
| 1006 | + * Returns [skip, existingId]. existingId is only meaningful for a natural-key | |
| 1007 | + * match — a child skipped because its parent was already here has no row of | |
| 1008 | + * its own to map to, and mapping it to the parent's id would corrupt anything | |
| 1009 | + * referencing the child (traveller meta being the obvious one). | |
| 1010 | + * | |
| 1011 | + * @param array<string, mixed> $record | |
| 1012 | + * @return array{0: bool, 1: int|null} | |
| 1013 | + */ | |
| 1014 | + private static function findExistingRowId( | |
| 1015 | + ExportImportRepository $repository, | |
| 1016 | + string $tableName, | |
| 1017 | + string $dataType, | |
| 1018 | + array $record, | |
| 1019 | + ExportImportIdMapper $mapper | |
| 1020 | + ): array { | |
| 1021 | + $naturalKeys = (array) apply_filters('yatra_import_natural_keys', self::IMPORT_NATURAL_KEYS); | |
| 1022 | + | |
| 1023 | + if (isset($naturalKeys[$dataType])) { | |
| 1024 | + $criteria = []; | |
| 1025 | + foreach ((array) $naturalKeys[$dataType] as $column) { | |
| 1026 | + if (!array_key_exists($column, $record)) { | |
| 1027 | + return [false, null]; | |
| 1028 | + } | |
| 1029 | + $value = $record[$column]; | |
| 1030 | + | |
| 1031 | + // A null part of a composite key is meaningful — an availability | |
| 1032 | + // date with no departure time is still that date — so it is | |
| 1033 | + // matched as IS NULL rather than abandoning the lookup. Only a | |
| 1034 | + // key that is empty in every part identifies nothing. | |
| 1035 | + if ($value === null || $value === '') { | |
| 1036 | + $criteria[$column] = null; | |
| 1037 | + continue; | |
| 1038 | + } | |
| 1039 | + if (!is_scalar($value)) { | |
| 1040 | + return [false, null]; | |
| 1041 | + } | |
| 1042 | + | |
| 1043 | + $keyFks = self::IMPORT_NATURAL_KEY_FKS[$dataType] ?? []; | |
| 1044 | + if (isset($keyFks[$column])) { | |
| 1045 | + $mapped = $mapper->map($keyFks[$column], $value); | |
| 1046 | + if ($mapped === null) { | |
| 1047 | + // Parent not on this site: nothing here can match. | |
| 1048 | + return [false, null]; | |
| 1049 | + } | |
| 1050 | + $value = $mapped; | |
| 1051 | + } | |
| 1052 | + | |
| 1053 | + $criteria[$column] = $value; | |
| 1054 | + } | |
| 1055 | + | |
| 1056 | + if ($criteria === [] || count(array_filter($criteria, static fn($v) => $v !== null)) === 0) { | |
| 1057 | + return [false, null]; | |
| 1058 | + } | |
| 1059 | + | |
| 1060 | + $existingId = $repository->findRowIdBy($tableName, $criteria); | |
| 1061 | + | |
| 1062 | + return $existingId !== null ? [true, $existingId] : [false, null]; | |
| 1063 | + } | |
| 1064 | + | |
| 1065 | + $parents = (array) apply_filters('yatra_import_primary_parent', self::IMPORT_PRIMARY_PARENT); | |
| 1066 | + foreach ((array) ($parents[$dataType] ?? []) as $parent) { | |
| 1067 | + [$parentEntity, $fkColumn] = $parent; | |
| 1068 | + if (empty($record[$fkColumn])) { | |
| 1069 | + continue; | |
| 1070 | + } | |
| 1071 | + $parentOldId = (int) $record[$fkColumn]; | |
| 1072 | + | |
| 1073 | + // Matched on the export's id rather than ours: a skipped row has no | |
| 1074 | + // id here, so anything hanging off it could not be resolved any | |
| 1075 | + // other way. | |
| 1076 | + if ($parentOldId > 0 && !empty(self::$importSkippedOldIds[$parentEntity][$parentOldId])) { | |
| 1077 | + return [true, null]; | |
| 1078 | + } | |
| 1079 | + | |
| 1080 | + $mappedParentId = (int) $mapper->map($parentEntity, $record[$fkColumn]); | |
| 1081 | + if ($mappedParentId > 0 && !empty(self::$importPreExisting[$parentEntity][$mappedParentId])) { | |
| 1082 | + return [true, null]; | |
| 1083 | + } | |
| 1084 | + } | |
| 1085 | + | |
| 1086 | + return [false, null]; | |
| 1087 | + } | |
| 1088 | + | |
| 1089 | + /** | |
| 816 | 1090 | * @param array<int, mixed> $records |
| 817 | 1091 | * @param array<string, array{total: int, imported: int, failed: int}> $importStats |
| 818 | 1092 | */ |
| 819 | 1093 | private static function importTableRowsWithMapping( |
| @@ -832,8 +1106,9 @@ | ||
| 832 | 1106 | $importStats[$dataType] = [ |
| 833 | 1107 | 'total' => count($records), |
| 834 | 1108 | 'imported' => 0, |
| 835 | 1109 | 'failed' => 0, |
| 1110 | + 'skipped' => 0, | |
| 836 | 1111 | ]; |
| 837 | 1112 | Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records'); |
| 838 | 1113 | |
| 839 | 1114 | foreach (array_chunk($records, self::BATCH_SIZE) as $batch) { |
| @@ -842,8 +1117,28 @@ | ||
| 842 | 1117 | $oldId = (int) ($record['id'] ?? 0); |
| 843 | 1118 | unset($record['id']); |
| 844 | 1119 | |
| 845 | 1120 | try { |
| 1121 | + // Recognise a row this site already has. Deliberately before the | |
| 1122 | + // ensureUnique* helpers below, which would otherwise rename the | |
| 1123 | + // very value being matched on ("SUMMER10" -> "SUMMER10-i1") and | |
| 1124 | + // turn every re-import into a fresh duplicate. | |
| 1125 | + [$alreadyHere, $existingId] = self::findExistingRowId($repository, $tableName, $dataType, $record, $mapper); | |
| 1126 | + if ($alreadyHere) { | |
| 1127 | + $importStats[$dataType]['skipped']++; | |
| 1128 | + $entity = self::entityKeyForDataType($dataType); | |
| 1129 | + if ($entity !== null && $oldId > 0) { | |
| 1130 | + self::$importSkippedOldIds[$entity][$oldId] = true; | |
| 1131 | + } | |
| 1132 | + if ($entity !== null && $oldId > 0 && $existingId !== null) { | |
| 1133 | + // Point this export's id at the row already here, so | |
| 1134 | + // children attach to it instead of dangling. | |
| 1135 | + $mapper->remember($entity, $oldId, $existingId); | |
| 1136 | + self::$importPreExisting[$entity][$existingId] = true; | |
| 1137 | + } | |
| 1138 | + continue; | |
| 1139 | + } | |
| 1140 | + | |
| 846 | 1141 | self::applyForeignKeyRemapping($mapper, $dataType, $record); |
| 847 | 1142 | |
| 848 | 1143 | if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') { |
| 849 | 1144 | $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']); |
| @@ -936,8 +1231,12 @@ | ||
| 936 | 1231 | case 'destinations': |
| 937 | 1232 | case 'activities': |
| 938 | 1233 | case 'categories': |
| 939 | 1234 | case 'difficulty_levels': |
| 1235 | + case 'traveler_categories': | |
| 1236 | + case 'attributes': | |
| 1237 | + case 'itinerary_item_types': | |
| 1238 | + case 'itinerary_items': | |
| 940 | 1239 | return 'classifications'; |
| 941 | 1240 | case 'trips': |
| 942 | 1241 | return 'trips'; |
| 943 | 1242 | case 'customers': |
| @@ -1255,12 +1554,43 @@ | ||
| 1255 | 1554 | |
| 1256 | 1555 | return $single !== null ? (string) $single : ''; |
| 1257 | 1556 | } |
| 1258 | 1557 | |
| 1558 | + /** | |
| 1559 | + * Does this table exist? | |
| 1560 | + * | |
| 1561 | + * The import de-duplicates keys against tables owned by Yatra Pro modules. | |
| 1562 | + * On a free-only site — or one where the owning module has never been | |
| 1563 | + * enabled — those tables are absent, and probing one logs a database error | |
| 1564 | + * for every record imported. Nothing to collide with in that case, so the | |
| 1565 | + * callers below keep the incoming value. | |
| 1566 | + * | |
| 1567 | + * Cached for the request: each caller runs once per imported row. | |
| 1568 | + */ | |
| 1569 | + private static function tableExists(string $table): bool | |
| 1570 | + { | |
| 1571 | + static $cache = []; | |
| 1572 | + | |
| 1573 | + if (!array_key_exists($table, $cache)) { | |
| 1574 | + global $wpdb; | |
| 1575 | + $cache[$table] = (bool) $wpdb->get_var( | |
| 1576 | + $wpdb->prepare('SHOW TABLES LIKE %s', $table) | |
| 1577 | + ); | |
| 1578 | + } | |
| 1579 | + | |
| 1580 | + return $cache[$table]; | |
| 1581 | + } | |
| 1582 | + | |
| 1259 | 1583 | private static function ensureUniqueConsentRequestToken(string $token): string |
| 1260 | 1584 | { |
| 1261 | 1585 | global $wpdb; |
| 1262 | 1586 | $table = $wpdb->prefix . 'yatra_consent_requests'; |
| 1587 | + | |
| 1588 | + // Trip Consent is a Pro module; without it there is no table to clash with. | |
| 1589 | + if (!self::tableExists($table)) { | |
| 1590 | + return $token; | |
| 1591 | + } | |
| 1592 | + | |
| 1263 | 1593 | $base = $token; |
| 1264 | 1594 | $candidate = $base; |
| 1265 | 1595 | for ($n = 0; $n < 5000; $n++) { |
| 1266 | 1596 | $exists = (int) $wpdb->get_var( |
| @@ -1281,8 +1611,14 @@ | ||
| 1281 | 1611 | private static function ensureUniqueEmailTemplateKey(string $key): string |
| 1282 | 1612 | { |
| 1283 | 1613 | global $wpdb; |
| 1284 | 1614 | $table = $wpdb->prefix . 'yatra_email_templates'; |
| 1615 | + | |
| 1616 | + // Email Automation is a Pro module; without it there is no table to clash with. | |
| 1617 | + if (!self::tableExists($table)) { | |
| 1618 | + return $key; | |
| 1619 | + } | |
| 1620 | + | |
| 1285 | 1621 | $base = $key; |
| 1286 | 1622 | $candidate = $base; |
| 1287 | 1623 | for ($n = 0; $n < 5000; $n++) { |
| 1288 | 1624 | $exists = (int) $wpdb->get_var( |
| @@ -1367,8 +1703,16 @@ | ||
| 1367 | 1703 | case 'categories': |
| 1368 | 1704 | return ClassificationTypes::CATEGORY; |
| 1369 | 1705 | case 'difficulty_levels': |
| 1370 | 1706 | return ClassificationTypes::DIFFICULTY; |
| 1707 | + case 'traveler_categories': | |
| 1708 | + return ClassificationTypes::TRAVELER_TYPE; | |
| 1709 | + case 'attributes': | |
| 1710 | + return ClassificationTypes::ATTRIBUTE; | |
| 1711 | + case 'itinerary_item_types': | |
| 1712 | + return ClassificationTypes::ITEM_TYPE; | |
| 1713 | + case 'itinerary_items': | |
| 1714 | + return ClassificationTypes::ITEM; | |
| 1371 | 1715 | default: |
| 1372 | 1716 | return null; |
| 1373 | 1717 | } |
| 1374 | 1718 | } |
| @@ -1413,8 +1757,12 @@ | ||
| 1413 | 1757 | 'destinations', |
| 1414 | 1758 | 'activities', |
| 1415 | 1759 | 'categories', |
| 1416 | 1760 | 'difficulty_levels', |
| 1761 | + 'traveler_categories', | |
| 1762 | + 'attributes', | |
| 1763 | + 'itinerary_item_types', | |
| 1764 | + 'itinerary_items', | |
| 1417 | 1765 | 'additional_service_catalog', |
| 1418 | 1766 | 'consent_forms', |
| 1419 | 1767 | 'email_templates', |
| 1420 | 1768 | 'email_sequences', |
| @@ -1542,8 +1890,10 @@ | ||
| 1542 | 1890 | * Import settings |
| 1543 | 1891 | */ |
| 1544 | 1892 | private static function importSettings(array $settings): void |
| 1545 | 1893 | { |
| 1894 | + $permalinkChanged = false; | |
| 1895 | + | |
| 1546 | 1896 | foreach ($settings as $key => $value) { |
| 1547 | 1897 | if (!is_string($key) || strpos($key, 'yatra_') !== 0) { |
| 1548 | 1898 | continue; |
| 1549 | 1899 | } |
| @@ -1549,10 +1899,47 @@ | ||
| 1549 | 1899 | } |
| 1550 | 1900 | if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) { |
| 1551 | 1901 | continue; |
| 1552 | 1902 | } |
| 1903 | + | |
| 1904 | + if (!$permalinkChanged && self::settingAffectsPermalinks($key)) { | |
| 1905 | + $existing = get_option($key, null); | |
| 1906 | + if ($existing !== $value) { | |
| 1907 | + $permalinkChanged = true; | |
| 1908 | + } | |
| 1909 | + } | |
| 1910 | + | |
| 1553 | 1911 | update_option($key, $value); |
| 1554 | 1912 | } |
| 1913 | + | |
| 1914 | + // Settings are cached per request, so without this the rest of the | |
| 1915 | + // import still sees the pre-import values. | |
| 1916 | + if (class_exists(\Yatra\Services\SettingsService::class)) { | |
| 1917 | + \Yatra\Services\SettingsService::reload(); | |
| 1918 | + } | |
| 1919 | + | |
| 1920 | + // A restored permalink base rewrites the trip and taxonomy URLs, but the | |
| 1921 | + // rules behind them are only rebuilt on a flush. Without this the site | |
| 1922 | + // keeps serving the old structure until someone happens to open | |
| 1923 | + // Settings > Permalinks — until then trip links resolve to nothing and | |
| 1924 | + // WordPress sends the visitor to the front page. | |
| 1925 | + if ($permalinkChanged) { | |
| 1926 | + // Deliberately not flush_rewrite_rules() here: the rules currently in | |
| 1927 | + // memory were built during init from the pre-import bases, so | |
| 1928 | + // regenerating now would just write the stale set back. Clearing the | |
| 1929 | + // cached rules makes WordPress rebuild them on the next request, by | |
| 1930 | + // which time init has read the imported values. | |
| 1931 | + delete_option('rewrite_rules'); | |
| 1932 | + Logger::info('Import changed permalink settings; cached rewrite rules cleared for rebuild.'); | |
| 1933 | + } | |
| 1934 | + } | |
| 1935 | + | |
| 1936 | + /** | |
| 1937 | + * Does this option take part in building URLs? | |
| 1938 | + */ | |
| 1939 | + private static function settingAffectsPermalinks(string $key): bool | |
| 1940 | + { | |
| 1941 | + return (bool) preg_match('/_base$|_prefix$|_segment$|permalink/i', $key); | |
| 1555 | 1942 | } |
| 1556 | 1943 | |
| 1557 | 1944 | /** |
| 1558 | 1945 | * Delete a job and its associated files |