current table suffix, both unprefixed. * * @return array */ function wpbr_legacy_table_map() { return array( 'bam_batches' => 'wpbr_batches', 'bam_batch_items' => 'wpbr_batch_items', 'bam_import_log' => 'wpbr_import_log', 'bam_learned_matches' => 'wpbr_learned_matches', 'bam_aliases' => 'wpbr_aliases', 'bam_log' => 'wpbr_log', ); } /** * Legacy option key => current option key. * * @return array */ function wpbr_legacy_option_map() { return array( 'bam_db_version' => 'wpbr_db_version', 'bam_settings' => 'wpbr_settings', 'bam_aliases_seeded' => 'wpbr_aliases_seeded', 'bam_dirs_protected' => 'wpbr_dirs_protected', 'bam_adopted_notice' => 'wpbr_adopted_notice', ); } /** * Legacy transient key => current transient key. * * @return array */ function wpbr_legacy_transient_map() { return array( 'bam_exposure_check' => 'wpbr_exposure_check', ); } /** * Does a table exist? * * @param string $table Fully qualified table name. * @return bool */ function wpbr_table_exists($table) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHOW TABLES has no WordPress API, and the answer decides whether to migrate, so it is read live. return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table; } /** * Decide whether the migration should run, and whether it safely can. * * Returns an array with: * 'action' => 'none' | 'migrate' * 'reason' => human-readable explanation * 'tables' => legacy => current, fully qualified, for the tables to move * * @return array */ function wpbr_migration_preflight() { global $wpdb; if (get_option('wpbr_schema_version') === WPBR_SCHEMA_VERSION) { return array('action' => 'none', 'code' => 'already_migrated', 'reason' => __('Already migrated.', 'wpbr-bulk-replace'), 'tables' => array()); } $legacy_present = array(); $current_present = array(); $tables = array(); foreach (wpbr_legacy_table_map() as $legacy => $current) { $legacy_full = $wpdb->prefix . $legacy; $current_full = $wpdb->prefix . $current; if (wpbr_table_exists($legacy_full)) $legacy_present[] = $legacy_full; if (wpbr_table_exists($current_full)) $current_present[] = $current_full; $tables[$legacy_full] = $current_full; } // Fresh install: nothing legacy to move. if (!$legacy_present) { return array('action' => 'none', 'code' => 'no_legacy_tables', 'reason' => __('No legacy tables found; nothing to migrate.', 'wpbr-bulk-replace'), 'tables' => array()); } // A partial legacy set means something is already wrong. Renaming what is // there would compound it and destroy the evidence. if (count($legacy_present) !== count(wpbr_legacy_table_map())) { return array( 'action' => 'none', 'code' => 'partial_legacy_set', 'reason' => sprintf( /* translators: 1: number of tables found, 2: number expected. */ __('Only %1$s of %2$s legacy tables are present. Refusing to migrate a partial set.', 'wpbr-bulk-replace'), number_format_i18n(count($legacy_present)), number_format_i18n(count(wpbr_legacy_table_map())) ), 'tables' => array(), ); } // Target names must be free, or RENAME TABLE fails halfway through the // statement and we would not know which state we were left in. if ($current_present) { return array( 'action' => 'none', 'code' => 'targets_exist', 'reason' => sprintf( /* translators: %s is a comma-separated list of database table names. */ __('These target tables already exist: %s. Refusing to overwrite them.', 'wpbr-bulk-replace'), implode(', ', $current_present) ), 'tables' => array(), ); } return array('action' => 'migrate', 'code' => 'ready', 'reason' => __('Legacy data found and target names are free.', 'wpbr-bulk-replace'), 'tables' => $tables); } /* ------------------------------------------------------------------------- * Converting implicit legacy behaviour into explicit configuration. * * Before Slice 3 the plugin hardcoded two things: a degree-level hard filter * that switched itself on whenever the post type was called "program", and a * noise-word list containing academic terms. Neither was visible or editable. * * Fresh installs now ship neither. Installs that relied on them get the same * behaviour back as ordinary configuration they can see, edit and delete - so * nothing regresses, and no special case survives in the matcher. * ---------------------------------------------------------------------- */ /** * The degree-level rule the old hardcoded filter implemented, expressed as an * ordinary variant group. * * The old regexes needed a negative lookahead to stop "MSc" being read as * "MS"; longest-token-wins makes that unnecessary, so MSc is simply its own * variant. Also kept as an importable asset in * docs/presets/higher-education.json. * * @return array */ function wpbr_legacy_degree_variant_group() { return array( 'id' => 'degree-level', 'label' => 'Degree level', 'enabled' => true, 'post_types' => array(), 'source' => 'preset', 'variants' => array( array('key' => 'phd', 'label' => "Doctor of Philosophy", 'tokens' => array('phd', 'ph d')), array('key' => 'edd', 'label' => "Doctor of Education", 'tokens' => array('edd', 'ed d')), array('key' => 'eds', 'label' => "Education Specialist", 'tokens' => array('eds', 'ed s')), array('key' => 'dba', 'label' => "Doctor of Business Administration", 'tokens' => array('dba')), array('key' => 'dsl', 'label' => "Doctor of Strategic Leadership", 'tokens' => array('dsl')), array('key' => 'dnp', 'label' => "Doctor of Nursing Practice", 'tokens' => array('dnp')), array('key' => 'dmin', 'label' => "Doctor of Ministry", 'tokens' => array('dmin', 'd min')), array('key' => 'psyd', 'label' => "Doctor of Psychology", 'tokens' => array('psyd', 'psy d')), array('key' => 'llm', 'label' => "Master of Laws", 'tokens' => array('llm', 'll m')), array('key' => 'jd', 'label' => "Juris Doctor", 'tokens' => array('jd', 'j d', 'juris doctor')), array('key' => 'mba', 'label' => "Master of Business Administration", 'tokens' => array('mba', 'master of business administration')), array('key' => 'mfa', 'label' => "Master of Fine Arts", 'tokens' => array('mfa', 'master of fine arts')), array('key' => 'mpa', 'label' => "Master of Public Administration", 'tokens' => array('mpa', 'master of public administration')), array('key' => 'msn', 'label' => "Master of Science in Nursing", 'tokens' => array('msn')), array('key' => 'mdiv', 'label' => "Master of Divinity", 'tokens' => array('mdiv', 'm div')), array('key' => 'thm', 'label' => "Master of Theology", 'tokens' => array('thm', 'th m')), array('key' => 'med', 'label' => "Master of Education", 'tokens' => array('med', 'm ed')), array('key' => 'mls', 'label' => "Master of Legal Studies", 'tokens' => array('mls', 'master of legal studies')), array('key' => 'msc', 'label' => "Master of Science (MSc)", 'tokens' => array('msc', 'm sc')), array('key' => 'ms', 'label' => "Master of Science", 'tokens' => array('ms', 'm s')), array('key' => 'ma', 'label' => "Master of Arts", 'tokens' => array('ma', 'm a', 'master of arts')), array('key' => 'bfa', 'label' => "Bachelor of Fine Arts", 'tokens' => array('bfa', 'bachelor of fine arts')), array('key' => 'bm', 'label' => "Bachelor of Music", 'tokens' => array('bm', 'b m')), array('key' => 'bed', 'label' => "Bachelor of Education", 'tokens' => array('bed', 'b ed')), array('key' => 'ba', 'label' => "Bachelor of Arts", 'tokens' => array('ba', 'b a', 'bachelor of arts')), array('key' => 'bs', 'label' => "Bachelor of Science", 'tokens' => array('bs', 'b s', 'bachelor of science')), array('key' => 'aa', 'label' => "Associate of Arts", 'tokens' => array('aa', 'a a', 'associate of arts')), array('key' => 'as', 'label' => "Associate of Science", 'tokens' => array('as', 'a s', 'associate of science')), array('key' => 'cgs', 'label' => "Certificate of Graduate Studies", 'tokens' => array('cgs', 'certificate of graduate studies', 'graduate certificate')), array('key' => 'cpm', 'label' => "Post-Master's Certificate", 'tokens' => array('cpm', 'post masters certificate', 'post master s certificate')), array('key' => 'cpd', 'label' => "Post-Doctoral Certificate", 'tokens' => array('cpd', 'cpds', 'post doctoral')), array('key' => 'ugcert', 'label' => "Undergraduate Certificate", 'tokens' => array('ugcert', 'undergraduate certificate')), array('key' => 'minor', 'label' => "Minor", 'tokens' => array('minor')), ), ); } /** * The exact noise-word list the 0.3.0 matcher used, academic terms included. * * @return string[] */ function wpbr_legacy_noise_words() { return array( 'updated', 'update', 'final', 'revised', 'revision', 'draft', 'copy', 'new', 'current', 'latest', 'ru', 'rev', 'version', 'ver', 'degree', 'worksheet', 'adp', 'approved', 'plan', 'with', 'without', 'in', 'of', 'the', 'and', 'a', ); } /** * Did this install carry the old higher-education vocabulary? * * Detected from the seeded alias rows themselves rather than from the * wpbr_aliases_seeded option. That option is only ever carried over from a BAM * install now: Free stopped seeding a vocabulary when managed vocabulary moved * to the Pro add-on, so a fresh install never sets it and its absence * says nothing about what an upgraded site holds. * * @return bool */ function wpbr_install_has_legacy_vocabulary() { global $wpdb; $table = $wpdb->prefix . 'wpbr_aliases'; if (!wpbr_table_exists($table)) return false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counts rows in a table from an earlier release to decide whether to migrate; no WordPress API, and it must be read live. return (int) $wpdb->get_var("SELECT COUNT(*) FROM `" . esc_sql($table) . "` WHERE source = 'seed'") > 0; } /** * Write the legacy behaviour out as configuration. * * Never overwrites anything already configured: a site that has since set its * own groups or ignore terms has made a decision, and this is a one-time * conversion, not an opinion. * * The two halves have different fates now that * managed vocabulary is Pro's. The variant group is Free's * and is applied as soon as it is written. The ignore terms are managed * vocabulary, which is the Pro add-on's layer, so Free records them and stops * there - they are preserved rather than discarded precisely because they are * the site's own knowledge, and the add-on reads them back through the * wpbr_noise_words filter. Losing them because ownership moved would be the * one outcome nobody could undo. * * @return array What was converted. */ function wpbr_migrate_matching_config() { $converted = array('variant_group' => false, 'noise_words' => false); if (!wpbr_install_has_legacy_vocabulary()) { return $converted; // Fresh install: stays neutral. } $groups = get_option('wpbr_variant_groups', null); if (!is_array($groups) || !$groups) { update_option('wpbr_variant_groups', array(wpbr_legacy_degree_variant_group())); $converted['variant_group'] = true; } $settings = get_option('wpbr_settings', array()); if (!is_array($settings)) $settings = array(); if (!isset($settings['noise_words'])) { $settings['noise_words'] = wpbr_legacy_noise_words(); update_option('wpbr_settings', $settings); $converted['noise_words'] = true; } return $converted; } /** * Reverse a completed rename, so a verification failure does not strand the * install between two worlds. * * @param array $tables legacy => current, fully qualified. * @return bool True when every legacy name is back. */ function wpbr_reverse_rename($tables) { global $wpdb; // The exact reverse of the statement wpbr_run_migration() ran: the same six // pairs, in one statement so they come back together or not at all. Fixed // text, as that one is - see wpbr_legacy_table_map(), which both follow. $suppress = $wpdb->suppress_errors(true); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- An atomic multi-table RENAME TABLE has no WordPress API; a fixed statement over the plugin's own tables. $ok = $wpdb->query( "RENAME TABLE `{$wpdb->prefix}wpbr_batches` TO `{$wpdb->prefix}bam_batches`, `{$wpdb->prefix}wpbr_batch_items` TO `{$wpdb->prefix}bam_batch_items`, `{$wpdb->prefix}wpbr_import_log` TO `{$wpdb->prefix}bam_import_log`, `{$wpdb->prefix}wpbr_learned_matches` TO `{$wpdb->prefix}bam_learned_matches`, `{$wpdb->prefix}wpbr_aliases` TO `{$wpdb->prefix}bam_aliases`, `{$wpdb->prefix}wpbr_log` TO `{$wpdb->prefix}bam_log`" ); $wpdb->suppress_errors($suppress); if ($ok === false) { return false; } // Confirm rather than assume: this runs precisely because something // already failed to be what it claimed. foreach (array_keys($tables) as $legacy) { if (!wpbr_table_exists($legacy)) { return false; } } return true; } /** * Run the migration if pre-flight allows it. * * Statuses, which the admin notices report verbatim because the difference * between them is the difference between "do nothing" and "restore a backup": * * skipped Nothing to do. Legacy schema untouched. * migrated Everything moved and verified. Complete. * incomplete Tables moved and verified, but at least one option could not * be copied and verified. The legacy option is still in place * and the schema version is NOT recorded, so the next admin * request retries. * failed Pre-flight refused, or the forward rename failed. Legacy * schema untouched. * rolled_back Rename succeeded but verification failed; the rename was * reversed and the legacy names are back. * critical Rename succeeded, verification failed, and the reversal also * failed. Manual recovery required. * * @return array */ function wpbr_run_migration() { global $wpdb; $pre = wpbr_migration_preflight(); if ($pre['action'] !== 'migrate') { // "No legacy tables" covers two different situations: a genuinely // fresh install, and an install whose tables moved on an earlier pass // while one or more options did not. Only the option phase can tell // them apart, so it runs before completion is declared - otherwise a // retry after an incomplete run would record the version and strand // the outstanding option under its legacy key permanently. // Compared against the machine code, never the prose. The reason text // is translated, so a site running in any other language would have // fallen through here and recorded the schema version without ever // migrating the options - stranding them under their legacy keys // permanently, which is the exact failure this branch exists to stop. if (isset($pre['code']) && $pre['code'] === 'no_legacy_tables') { $options = wpbr_migrate_options(); $transients = wpbr_migrate_transients(); if (!$options['ok'] || !$transients['ok']) { return array( 'status' => 'incomplete', 'detail' => sprintf( /* translators: %s is a comma-separated list of option names. */ __('Settings are still being migrated. Could not move: %s. The originals are still in place and this will be retried on the next admin page load.', 'wpbr-bulk-replace'), implode(', ', $options['failed']) ), 'tables' => 0, 'rows' => 0, 'options' => $options['moved'], 'failed' => $options['failed'], ); } wpbr_migrate_matching_config(); update_option('wpbr_schema_version', WPBR_SCHEMA_VERSION); if ($options['moved'] > 0) { return array( 'status' => 'migrated', 'detail' => sprintf( /* translators: %s is a number of settings. */ _n('Moved %s remaining setting; migration complete.', 'Moved %s remaining settings; migration complete.', $options['moved'], 'wpbr-bulk-replace'), number_format_i18n($options['moved']) ), 'tables' => 0, 'options' => $options['moved'], 'rows' => 0, 'failed' => array(), ); } } return array('status' => 'skipped', 'detail' => $pre['reason']); } // Row counts before, so the move can be verified rather than assumed. $before = array(); foreach ($pre['tables'] as $legacy => $current) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counts the rows of a plugin table before and after the rename to prove none was lost; must be read live. $before[$legacy] = (int) $wpdb->get_var('SELECT COUNT(*) FROM `' . esc_sql($legacy) . '`'); } // One statement, all six pairs. MySQL renames them atomically: if any pair // cannot be renamed, none of them are. // // Fixed text, because the six pairs are fixed: wpbr_legacy_table_map() is the // inventory, the pre-flight above refuses to get here unless every one of // the six legacy tables is present and none of the six targets is, and so // there is no set of tables this could be asked to move other than this one. // tests/MigrationTest.php fails if the statement and the map disagree. $suppress = $wpdb->suppress_errors(true); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic multi-table RENAME TABLE has no WordPress API; a fixed statement over the plugin's own tables. $renamed = $wpdb->query( "RENAME TABLE `{$wpdb->prefix}bam_batches` TO `{$wpdb->prefix}wpbr_batches`, `{$wpdb->prefix}bam_batch_items` TO `{$wpdb->prefix}wpbr_batch_items`, `{$wpdb->prefix}bam_import_log` TO `{$wpdb->prefix}wpbr_import_log`, `{$wpdb->prefix}bam_learned_matches` TO `{$wpdb->prefix}wpbr_learned_matches`, `{$wpdb->prefix}bam_aliases` TO `{$wpdb->prefix}wpbr_aliases`, `{$wpdb->prefix}bam_log` TO `{$wpdb->prefix}wpbr_log`" ); $wpdb->suppress_errors($suppress); if ($renamed === false) { return array( 'status' => 'failed', 'detail' => __('The table rename failed, so no tables were changed and the legacy data is exactly as it was.', 'wpbr-bulk-replace') . ' ' . __('Database error:', 'wpbr-bulk-replace') . ' ' . $wpdb->last_error, ); } // From here the schema HAS changed. Any failure below must either undo it // or say so plainly - claiming "nothing was changed" would be false. $problem = ''; foreach ($pre['tables'] as $legacy => $current) { if (!wpbr_table_exists($current)) { $problem = sprintf( /* translators: %s is a database table name. */ __('Table %s is missing after the rename.', 'wpbr-bulk-replace'), $current ); break; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Counts the rows of a plugin table before and after the rename to prove none was lost; must be read live. $after = (int) $wpdb->get_var('SELECT COUNT(*) FROM `' . esc_sql($current) . '`'); if ($after !== $before[$legacy]) { $problem = sprintf( /* translators: 1: table name, 2: expected row count, 3: row count found. */ __('Row count changed for %1$s: expected %2$s, found %3$s.', 'wpbr-bulk-replace'), $current, number_format_i18n($before[$legacy]), number_format_i18n($after) ); break; } } if ($problem !== '') { if (wpbr_reverse_rename($pre['tables'])) { return array( 'status' => 'rolled_back', 'detail' => $problem . ' ' . __('The rename was reversed and the legacy table names are back in place, so the previous plugin version can still read this database.', 'wpbr-bulk-replace'), ); } // The recovery instruction has to be usable from the notice itself. // It previously pointed at docs/MIGRATION.md, which .distignore // excludes from the release - so the one message that matters most was // referring the admin to a file their install does not contain. The // rename pairs they need are listed here instead. $pairs_text = array(); foreach ($pre['tables'] as $legacy => $current) { $pairs_text[] = $current . ' → ' . $legacy; } return array( 'status' => 'critical', 'detail' => $problem . ' ' . __('Reversing the rename also failed, so the database is left with the new table names but unverified contents. Do not process any batches.', 'wpbr-bulk-replace') . ' ' . sprintf( /* translators: %s is a list of "newname → oldname" database table pairs. */ __('Restore your database backup, or rename these tables back by hand: %s', 'wpbr-bulk-replace'), implode(', ', $pairs_text) ), ); } $options = wpbr_migrate_options(); $transients = wpbr_migrate_transients(); // The schema version is the gate that prevents any retry, so it is only // recorded when every part actually completed. Recording it after a // partial option move would strand a setting under its legacy key forever, // with the plugin reading only the new one. if (!$options['ok'] || !$transients['ok']) { $stuck = $options['failed'] ? ' ' . sprintf( /* translators: %s is a comma-separated list of option names. */ __('Could not move: %s.', 'wpbr-bulk-replace'), implode(', ', $options['failed']) ) : ''; return array( 'status' => 'incomplete', 'detail' => __('Tables were renamed and verified, but not every setting could be copied.', 'wpbr-bulk-replace') . $stuck . ' ' . __('The original settings are still in place and this will be retried automatically on the next admin page load.', 'wpbr-bulk-replace'), 'tables' => count($pre['tables']), 'rows' => array_sum($before), 'options' => $options['moved'], 'failed' => $options['failed'], ); } wpbr_migrate_matching_config(); update_option('wpbr_schema_version', WPBR_SCHEMA_VERSION); return array( 'status' => 'migrated', 'detail' => sprintf( /* translators: 1: number of database tables, 2: number of settings. */ __('Renamed %1$s tables and moved %2$s settings.', 'wpbr-bulk-replace'), number_format_i18n(count($pre['tables'])), number_format_i18n($options['moved']) ), 'tables' => count($pre['tables']), 'options' => $options['moved'], 'rows' => array_sum($before), 'failed' => array(), ); } /** * Copy options to their new keys, verifying each before dropping the old one. * * A legacy key is deleted only once its replacement has been written, read * back and compared. If that verification fails the legacy key stays put and * is reported, because it is still the only correct copy. * * @return array 'moved' => int, 'failed' => string[], 'ok' => bool */ function wpbr_migrate_options() { $moved = 0; $failed = array(); foreach (wpbr_legacy_option_map() as $legacy => $current) { $sentinel = '__wpbr_absent__'; $value = get_option($legacy, $sentinel); if ($value === $sentinel) { continue; // Never set on this install; nothing to move. } update_option($current, $value); $written = get_option($current, $sentinel); if ($written === $sentinel || $written != $value) { // Leave the legacy key alone: it is still the truth. $failed[] = $legacy; continue; } delete_option($legacy); $moved++; } return array('moved' => $moved, 'failed' => $failed, 'ok' => empty($failed)); } /** * Transients are a cache, so they are dropped rather than copied - the * exposure probe simply re-runs. * * @return array 'ok' => bool, 'failed' => string[] */ function wpbr_migrate_transients() { $failed = array(); foreach (wpbr_legacy_transient_map() as $legacy => $current) { delete_transient($legacy); if (get_transient($legacy) !== false) { $failed[] = $legacy; } } return array('ok' => empty($failed), 'failed' => $failed); }