Changeset 3764005 for langforge/trunk/includes/class-migration.php
- Timestamp:
- 10/25/2026 06:29:16 AM (27 hours ago)
- File:
-
- 1 edited
-
langforge/trunk/includes/class-migration.php (modified) (12 diffs)
Legend:
- Unmodified
- Added
- Removed
-
langforge/trunk/includes/class-migration.php
r3740148 r3764005 91 91 'count' => $count, 92 92 'note' => sprintf( 93 /* translators: 1: number of WPML translation records found, 2: number of languages. */ 94 __('Found %1$d translation records across %2$d languages.', 'langforge'), 95 $count, $lang_count 93 /* translators: 1: the record count with its noun ("%d translation record"), 2: the language count with its noun ("%d language"). */ 94 __('Found %1$s across %2$s.', 'langforge'), 95 /* translators: %d: number of WPML translation records found. */ 96 sprintf(_n('%d translation record', '%d translation records', (int) $count, 'langforge'), (int) $count), 97 /* translators: %d: number of languages. */ 98 sprintf(_n('%d language', '%d languages', (int) $lang_count, 'langforge'), (int) $lang_count) 96 99 ), 97 100 ]; … … 121 124 'count' => $post_count, 122 125 'note' => sprintf( 123 /* translators: 1: number of Polylang posts assigned to languages, 2: total number of languages. */ 124 __('Found %1$d posts assigned to languages, %2$d languages total.', 'langforge'), 125 $post_count, $lang_count 126 /* translators: 1: the post count with its noun ("%d post"), 2: the language count with its noun ("%d language"). */ 127 __('Found %1$s assigned to languages, %2$s total.', 'langforge'), 128 /* translators: %d: number of Polylang posts assigned to languages. */ 129 sprintf(_n('%d post', '%d posts', $post_count, 'langforge'), $post_count), 130 /* translators: %d: number of languages. */ 131 sprintf(_n('%d language', '%d languages', $lang_count, 'langforge'), $lang_count) 126 132 ), 127 133 ]; … … 137 143 'count' => $trp_stats['records'], 138 144 'note' => sprintf( 139 /* translators: 1: number of TranslatePress translated strings, 2: number of languages. */ 140 __('Found %1$d translated strings across %2$d languages.', 'langforge'), 141 $trp_stats['records'], $trp_stats['languages'] 145 /* translators: 1: the string count with its noun ("%d translated string"), 2: the language count with its noun ("%d language"). */ 146 __('Found %1$s across %2$s.', 'langforge'), 147 /* translators: %d: number of TranslatePress translated strings. */ 148 sprintf(_n('%d translated string', '%d translated strings', (int) $trp_stats['records'], 'langforge'), (int) $trp_stats['records']), 149 /* translators: %d: number of languages. */ 150 sprintf(_n('%d language', '%d languages', (int) $trp_stats['languages'], 'langforge'), (int) $trp_stats['languages']) 142 151 ), 143 152 ]; … … 366 375 // Clearing first makes re-migration idempotent. 367 376 if (!empty($rows)) { 368 $element_ids_csv = implode(',', array_map('intval', array_column($rows, 'element_id'))); 369 $wpdb->query("DELETE FROM {$lf_table} WHERE element_id IN ({$element_ids_csv})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; custom plugin table. 377 $this->delete_post_translation_rows(array_column($rows, 'element_id')); 370 378 } 371 379 … … 426 434 ); 427 435 436 // 5b. Translations of terms (categories, tags, any taxonomy): the `tax_*` rows of 437 // icl_translations. Counted apart from the posts: `migrated` stays the number of posts. 438 $term_result = $this->migrate_wpml_terms(); 439 $terms = $term_result['linked']; 440 $errors += $term_result['errors']; 441 if ($term_result['linked'] || $term_result['errors']) { 442 $log[] = $this->term_result_line($term_result); 443 } 444 428 445 // 6. Migrate WPML string translations if tables exist 429 446 $string_log = $this->migrate_wpml_strings(); … … 437 454 flush_rewrite_rules(); 438 455 439 return compact('migrated', 'skipped', 'errors', ' log');456 return compact('migrated', 'skipped', 'errors', 'terms', 'log'); 440 457 } 441 458 … … 702 719 'languages' => max(1, count($languages_with_records)), 703 720 ]; 721 } 722 723 /** 724 * Delete the translation rows of the POSTS with these numbers: a migration can be run again, and clears the 725 * rows of the posts it is about to write first. 726 * 727 * Posts' rows only (LANGFORGE_Post_Translation::post_row_sql): the table also holds the rows of terms, a term 728 * can have the number of a post, and a migration of posts must not delete what it does not write 729 * (BUG-LANG-SEO-03). One place for the three migrations (WPML, Polylang, TranslatePress). 730 * 731 * @param array $post_ids Post IDs; anything that is not a positive integer is dropped. 732 * @return int Rows deleted. 733 */ 734 private function delete_post_translation_rows(array $post_ids): int { 735 global $wpdb; 736 $ids = array_values(array_unique(array_filter(array_map('intval', $post_ids), function ($id) { return $id > 0; }))); 737 if (!$ids) { 738 return 0; 739 } 740 $lf_table = $wpdb->prefix . 'langforge_translations'; 741 $ids_csv = implode(',', $ids); 742 return (int) $wpdb->query("DELETE FROM {$lf_table} WHERE element_id IN ({$ids_csv}) AND " . LANGFORGE_Post_Translation::post_row_sql()); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; custom plugin table. 704 743 } 705 744 … … 948 987 update_post_meta($target_id, '_langforge_migrated_from_translatepress_language', $target_code); 949 988 950 $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration write 951 "DELETE FROM {$lf_table} WHERE element_id IN (%d, %d)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared. 952 $source_id, 953 $target_id 954 )); 989 $this->delete_post_translation_rows([$source_id, $target_id]); 955 990 $group = (int) $wpdb->get_var("SELECT COALESCE(MAX(translation_group), 0) FROM {$lf_table}") + 1; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read 956 991 … … 1016 1051 1017 1052 return $count; 1053 } 1054 1055 1056 // Translations of terms (WPML `tax_*`, Polylang `term_language` / `term_translations`) 1057 1058 1059 // BUG-LANG-MIGR-01: the migrations read posts only, so the translated categories were not linked 1060 // after them. wp_langforge_translations keeps a term the way the taxonomy screen of the plugin 1061 // writes it: element_type "tax_<taxonomy>", element_id the term_id, one translation_group for 1062 // the pair (the groups of posts and terms count in the same series). 1063 1064 /** 1065 * Taxonomies WPML registers for its own bookkeeping; its icl_translations rows of the type 1066 * "tax_<name>" are not translations of the content of the site. They exist on the site (taxonomy_exists 1067 * is true) as long as WPML is active, which it is when the migration runs. 1068 */ 1069 private const WPML_SERVICE_TAXONOMIES = ['translation_priority']; 1070 1071 /** The line of the log for the terms. */ 1072 private function term_result_line(array $result): string { 1073 return sprintf('Terms: linked %d translations in %d groups, errors %d', $result['linked'], $result['groups'], $result['errors']); 1074 } 1075 1076 /** 1077 * Clear the rows of the terms that are about to be written, so that a second run (migrate, tweak, migrate 1078 * again) does not leave one term in two groups. By element and type, never by number alone: a post and a 1079 * term can have the same number (see delete_post_translation_rows). 1080 * 1081 * @param array<string,int> $term_ids_by_taxonomy Taxonomy => term IDs. 1082 * @return int Rows deleted. 1083 */ 1084 private function delete_term_translation_rows(array $term_ids_by_taxonomy): int { 1085 global $wpdb; 1086 $lf_table = $wpdb->prefix . 'langforge_translations'; 1087 $deleted = 0; 1088 foreach ($term_ids_by_taxonomy as $taxonomy => $term_ids) { 1089 $taxonomy = sanitize_key((string) $taxonomy); 1090 $ids = array_values(array_unique(array_filter(array_map('intval', $term_ids), function ($id) { return $id > 0; }))); 1091 if ($taxonomy === '' || !$ids) { 1092 continue; 1093 } 1094 foreach (array_chunk($ids, 500) as $chunk) { 1095 $ids_csv = implode(',', $chunk); 1096 $deleted += (int) $wpdb->query($wpdb->prepare("DELETE FROM {$lf_table} WHERE element_type = %s AND element_id IN ({$ids_csv})", LANGFORGE_Post_Translation::TERM_ELEMENT_PREFIX . $taxonomy)); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; custom plugin table. 1097 } 1098 } 1099 return $deleted; 1100 } 1101 1102 /** 1103 * Write groups of term translations. 1104 * 1105 * @param array<int,array<int,array{term_id:int,taxonomy:string,language:string,source:?string}>> $groups One list of members per group. 1106 * @return array{linked:int,groups:int,errors:int} Rows written, groups with a row, rows that could not be written. 1107 */ 1108 private function write_term_translation_groups(array $groups): array { 1109 global $wpdb; 1110 $lf_table = $wpdb->prefix . 'langforge_translations'; 1111 $linked = $written = $errors = 0; 1112 1113 $by_taxonomy = []; 1114 foreach ($groups as $members) { 1115 foreach ($members as $member) { 1116 $by_taxonomy[$member['taxonomy']][] = $member['term_id']; 1117 } 1118 } 1119 if (!$by_taxonomy) { 1120 return ['linked' => 0, 'groups' => 0, 'errors' => 0]; 1121 } 1122 $this->delete_term_translation_rows($by_taxonomy); 1123 1124 $group_id = (int) $wpdb->get_var("SELECT COALESCE(MAX(translation_group), 0) FROM {$lf_table}"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read 1125 $seen = []; 1126 foreach ($groups as $members) { 1127 $group_id++; 1128 $has_row = false; 1129 foreach ($members as $member) { 1130 $element_type = LANGFORGE_Post_Translation::TERM_ELEMENT_PREFIX . $member['taxonomy']; 1131 $key = $element_type . ':' . $member['term_id'] . ':' . $member['language']; 1132 if (isset($seen[$key])) { 1133 continue; 1134 } 1135 $seen[$key] = true; 1136 $result = $wpdb->insert($lf_table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write 1137 'element_id' => $member['term_id'], 1138 'element_type' => $element_type, 1139 'translation_group' => $group_id, 1140 'language_code' => $member['language'], 1141 'source_language_code' => $member['source'], 1142 ]); 1143 if ($result === false) { 1144 $errors++; 1145 continue; 1146 } 1147 $linked++; 1148 $has_row = true; 1149 } 1150 if ($has_row) { 1151 $written++; 1152 } 1153 } 1154 1155 return ['linked' => $linked, 'groups' => $written, 'errors' => $errors]; 1156 } 1157 1158 /** 1159 * WPML keeps the translation of a term as a row of icl_translations of the type "tax_<taxonomy>": 1160 * element_id is the term_taxonomy_id of the term (not its term_id), the members of a pair share a trid. 1161 * 1162 * @return array{linked:int,groups:int,errors:int} 1163 */ 1164 private function migrate_wpml_terms(): array { 1165 global $wpdb; 1166 $icl_table = $wpdb->prefix . 'icl_translations'; 1167 1168 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy/core table names from $wpdb->prefix; static query. 1169 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read 1170 "SELECT t.element_type, t.trid, t.language_code, t.source_language_code, tt.term_id, tt.taxonomy 1171 FROM {$icl_table} t 1172 JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = t.element_id 1173 WHERE t.element_type LIKE 'tax_%' 1174 ORDER BY t.trid ASC, tt.term_id ASC" 1175 ); 1176 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1177 1178 $by_trid = []; 1179 foreach ((array) $rows as $row) { 1180 // The type of the row names the taxonomy of the term it points at; a number that is the 1181 // term_taxonomy_id of a term of another taxonomy is not this row's term. 1182 if ($row->element_type !== LANGFORGE_Post_Translation::TERM_ELEMENT_PREFIX . $row->taxonomy) { 1183 continue; 1184 } 1185 // `tax_*` is every taxonomy WPML keeps a row for: its own service taxonomies too, and the ones of 1186 // plugins that are gone. Only the taxonomies the site registers, and not WPML's own, are brought over. 1187 if (in_array($row->taxonomy, self::WPML_SERVICE_TAXONOMIES, true) || !taxonomy_exists($row->taxonomy)) { 1188 continue; 1189 } 1190 $language = sanitize_key($row->language_code); 1191 if ($language === '') { 1192 continue; 1193 } 1194 $by_trid[(int) $row->trid][] = [ 1195 'term_id' => (int) $row->term_id, 1196 'taxonomy' => sanitize_key($row->taxonomy), 1197 'language' => $language, 1198 'source' => $row->source_language_code ? sanitize_key($row->source_language_code) : null, 1199 ]; 1200 } 1201 1202 return $this->write_term_translation_groups(array_values($by_trid)); 1203 } 1204 1205 /** 1206 * Taxonomy of terms, by term_id: the taxonomy that is not one of Polylang's own. 1207 * 1208 * @param int $term_ids 1209 * @return array<int,string> term_id => taxonomy 1210 */ 1211 private function taxonomies_of_terms(array $term_ids): array { 1212 global $wpdb; 1213 $found = []; 1214 $ids = array_values(array_unique(array_filter(array_map('intval', $term_ids), function ($id) { return $id > 0; }))); 1215 foreach (array_chunk($ids, 500) as $chunk) { 1216 $ids_csv = implode(',', $chunk); 1217 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core table; IN list is integer IDs cast via intval above. 1218 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- core table; IN list is integer IDs cast via intval; one-time migration read 1219 "SELECT term_id, taxonomy FROM {$wpdb->term_taxonomy} 1220 WHERE term_id IN ({$ids_csv}) 1221 AND taxonomy NOT IN ('language', 'term_language', 'post_translations', 'term_translations') 1222 ORDER BY term_taxonomy_id ASC" 1223 ); 1224 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1225 foreach ((array) $rows as $row) { 1226 if (!isset($found[(int) $row->term_id])) { 1227 $found[(int) $row->term_id] = sanitize_key($row->taxonomy); 1228 } 1229 } 1230 } 1231 return $found; 1232 } 1233 1234 /** 1235 * The members of a map Polylang keeps in the description of a "post_translations" or "term_translations" 1236 * term: language code => ID. 1237 * 1238 * The map is serialized and carries more than the languages: a "sync" list (the posts or terms whose 1239 * data Polylang keeps in step), so a key that is not a language of Polylang is no member, and neither is 1240 * a value that is not a positive whole ID (a list is no ID: (int) of a non-empty one is 1). 1241 * 1242 * @param mixed $map Unserialized description. 1243 * @param array<string,mixed> $lang_info Languages of Polylang by code. 1244 * @return array<string,int> code => ID 1245 */ 1246 private function polylang_map_members($map, array $lang_info): array { 1247 if (!is_array($map)) { 1248 return []; 1249 } 1250 $members = []; 1251 foreach ($map as $code => $id) { 1252 $code = sanitize_key((string) $code); 1253 if ($code === '' || !isset($lang_info[$code]) || !is_scalar($id) || !preg_match('/^[1-9][0-9]*$/', (string) $id)) { 1254 continue; 1255 } 1256 $members[$code] = (int) $id; 1257 } 1258 return $members; 1259 } 1260 1261 /** 1262 * Polylang gives a term its language as a "term_language" term (slug pll_<code>) related to the term, and 1263 * keeps a pair as a "term_translations" term whose description is the serialized map code => term_id 1264 * (the same map as "post_translations", with a "sync" list of its own besides the languages). 1265 * 1266 * @param array<string,mixed> $lang_info Languages of the site by code (as read from Polylang). 1267 * @param string $default_lang 1268 * @return array{linked:int,groups:int,errors:int} 1269 */ 1270 private function migrate_polylang_terms(array $lang_info, string $default_lang): array { 1271 global $wpdb; 1272 1273 $pairs = []; 1274 $in_pair = []; 1275 $pair_terms = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read 1276 "SELECT tt.description FROM {$wpdb->term_taxonomy} tt WHERE tt.taxonomy = 'term_translations'" 1277 ); 1278 foreach ((array) $pair_terms as $pair_term) { 1279 $members = $this->polylang_map_members(forge_safe_unserialize($pair_term->description), $lang_info); 1280 foreach ($members as $term_id) { 1281 $in_pair[$term_id] = true; 1282 } 1283 if ($members) { 1284 $pairs[] = $members; 1285 } 1286 } 1287 1288 // Terms that have a language and no pair: a row of their own, as single posts have. 1289 $singles = []; 1290 $lang_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read 1291 "SELECT tr.object_id AS term_id, t.slug 1292 FROM {$wpdb->term_relationships} tr 1293 JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id 1294 JOIN {$wpdb->terms} t ON t.term_id = tt.term_id 1295 WHERE tt.taxonomy = 'term_language' 1296 ORDER BY tr.object_id ASC" 1297 ); 1298 foreach ((array) $lang_rows as $row) { 1299 $term_id = (int) $row->term_id; 1300 $code = sanitize_key((string) preg_replace('/^pll_/', '', (string) $row->slug)); 1301 if ($term_id <= 0 || $code === '' || isset($in_pair[$term_id]) || !isset($lang_info[$code])) { 1302 continue; 1303 } 1304 $singles[$term_id] = $code; 1305 } 1306 1307 $taxonomy_of = $this->taxonomies_of_terms(array_merge(array_keys($in_pair), array_keys($singles))); 1308 1309 $member = function (int $term_id, string $code) use ($taxonomy_of, $default_lang) { 1310 return [ 1311 'term_id' => $term_id, 1312 'taxonomy' => $taxonomy_of[$term_id], 1313 'language' => $code, 1314 'source' => $code === $default_lang ? null : ($default_lang !== '' ? $default_lang : null), 1315 ]; 1316 }; 1317 $groups = []; 1318 foreach ($pairs as $members) { 1319 $group = []; 1320 foreach ($members as $code => $term_id) { 1321 if (isset($taxonomy_of[$term_id])) { 1322 $group[] = $member($term_id, (string) $code); 1323 } 1324 } 1325 if ($group) { 1326 $groups[] = $group; 1327 } 1328 } 1329 foreach ($singles as $term_id => $code) { 1330 if (isset($taxonomy_of[$term_id])) { 1331 $groups[] = [$member((int) $term_id, $code)]; 1332 } 1333 } 1334 1335 return $this->write_term_translation_groups($groups); 1018 1336 } 1019 1337 … … 1091 1409 $group_post_ids = []; 1092 1410 foreach ($group_terms as $group_term) { 1093 $translations = forge_safe_unserialize($group_term->description); 1094 if (!is_array($translations) || count($translations) < 1) continue; 1411 // Only the languages of Polylang with a positive ID: the map carries a "sync" list besides them, 1412 // and (int) of a non-empty list is 1 (BUG-LANG-MIGR-02). 1413 $translations = $this->polylang_map_members(forge_safe_unserialize($group_term->description), $lang_info); 1414 if (!$translations) continue; 1095 1415 1096 1416 $translation_groups[] = $translations; … … 1111 1431 $all_polylang_post_ids = array_unique(array_filter(array_map('intval', array_merge($group_post_ids, $language_post_ids)))); 1112 1432 if (!empty($all_polylang_post_ids)) { 1113 $post_ids_csv = implode(',', $all_polylang_post_ids); 1114 $wpdb->query("DELETE FROM {$lf_table} WHERE element_id IN ({$post_ids_csv})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; custom plugin table. 1433 $this->delete_post_translation_rows($all_polylang_post_ids); 1115 1434 } 1116 1435 … … 1220 1539 ); 1221 1540 1541 // 8. Translations of terms: the `term_translations` pairs and the `term_language` of single terms. 1542 $term_result = $this->migrate_polylang_terms($lang_info, (string) $default_lang); 1543 $terms = $term_result['linked']; 1544 $errors += $term_result['errors']; 1545 if ($term_result['linked'] || $term_result['errors']) { 1546 $log[] = $this->term_result_line($term_result); 1547 } 1548 1222 1549 // same flush as WPML — new CPT rules need to land in wp_options. 1223 1550 flush_rewrite_rules(); 1224 1551 1225 return compact('migrated', 'skipped', 'errors', ' log');1552 return compact('migrated', 'skipped', 'errors', 'terms', 'log'); 1226 1553 } 1227 1554
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)