*/ public static function steps() { return array( 1 => array( __CLASS__, 'v1_initial_settings' ), 2 => array( __CLASS__, 'v2_diagnostics_tables' ), 3 => array( __CLASS__, 'v3_repair_tables' ), 4 => array( __CLASS__, 'v4_monitor_tables' ), 5 => array( __CLASS__, 'v5_clean_values' ), 6 => array( __CLASS__, 'v6_page_templates' ), 7 => array( __CLASS__, 'v7_dot_segments' ), ); } /** * Highest schema version known to this code. * * @return int */ public static function latest() { return (int) max( array_keys( self::steps() ) ); } /** * Stored schema version (0 when never installed). * * @return int */ public static function current() { return (int) get_option( self::OPTION, 0 ); } /** * Runs any pending steps. Cheap when up to date: one autoloaded option read. * * @return bool True when the stored version is current after the call. */ public static function maybe_run() { $current = self::current(); if ( $current >= self::latest() ) { return true; } if ( ! Lock::acquire( self::LOCK, 120 ) ) { return false; } try { foreach ( self::steps() as $version => $step ) { if ( $version <= $current ) { continue; } call_user_func( $step ); update_option( self::OPTION, $version, true ); $current = $version; } } finally { Lock::release( self::LOCK ); } return $current >= self::latest(); } /** * Version 1: store default settings if none exist. */ public static function v1_initial_settings() { add_option( Settings::OPTION, Settings::defaults(), '', true ); } /** * Version 2: link index and URL check tables. */ public static function v2_diagnostics_tables() { \Orbitstem\SEO\Diagnostics\Tables::create(); } /** * Version 3: redirects, 404 log and change log tables. */ public static function v3_repair_tables() { \Orbitstem\SEO\Diagnostics\Tables::create_v3(); } /** * Version 4: regression monitor snapshot tables. */ public static function v4_monitor_tables() { \Orbitstem\SEO\Diagnostics\Tables::create_v4(); } /** * Version 6: saved templates that still equal the defaults of earlier * versions get the current defaults, which include %page% (so page 2 of an * archive or split post has its own title). Templates someone wrote are kept. */ public static function v6_page_templates() { $stored = get_option( Settings::OPTION, null ); if ( ! is_array( $stored ) ) { return; } $legacy = Settings::legacy_defaults(); if ( isset( $stored['home_title'] ) && $legacy['home_title'] === $stored['home_title'] ) { $stored['home_title'] = Settings::defaults()['home_title']; } foreach ( isset( $stored['post_types'] ) && is_array( $stored['post_types'] ) ? array_keys( $stored['post_types'] ) : array() as $type ) { if ( isset( $stored['post_types'][ $type ]['title'] ) && $legacy['post_type_title'] === $stored['post_types'][ $type ]['title'] ) { $stored['post_types'][ $type ]['title'] = Settings::post_type_defaults( $type )['title']; } } update_option( Settings::OPTION, $stored ); } /** * Version 5: removes empty SEO field rows (left by block-editor saves in * earlier versions, they counted as "set"), and re-normalizes exact redirect * sources so encoded and non-Latin addresses match (e.g. /привет). */ public static function v5_clean_values() { global $wpdb; $keys = \Orbitstem\SEO\Content\MetaRepository::meta_keys(); $in = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); $prefix = \Orbitstem\SEO\Content\MetaRepository::PREFIX; // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $in is a placeholder list built above. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ({$in}) AND meta_value = ''", ...$keys ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->termmeta} WHERE meta_key IN ({$in}) AND meta_value = ''", ...$keys ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ( %s, %s ) AND meta_value = '0'", $prefix . 'noindex', $prefix . 'nofollow' ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->termmeta} WHERE meta_key IN ( %s, %s ) AND meta_value = '0'", $prefix . 'noindex', $prefix . 'nofollow' ) ); // phpcs:enable if ( ! wp_cache_flush_group( 'post_meta' ) ) { wp_cache_flush(); } self::renormalize_sources(); } /** * Version 7: exact redirect sources are stored without "." and ".." * segments or doubled slashes, so "/old/./page" matches "/old/page". */ public static function v7_dot_segments() { self::renormalize_sources(); } /** * Stores every exact redirect source in its current normalized form. A rule * whose normalized source another rule already has is left as it is. */ private static function renormalize_sources() { global $wpdb; $table = \Orbitstem\SEO\Diagnostics\Tables::redirects(); if ( ! \Orbitstem\SEO\Diagnostics\Tables::v3_ready() ) { return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rules = $wpdb->get_results( $wpdb->prepare( 'SELECT id, source, source_hash FROM %i WHERE is_regex = 0', $table ), ARRAY_A ); foreach ( $rules as $rule ) { $source = \Orbitstem\SEO\Redirects\Repository::normalize_path( $rule['source'] ); $hash = \Orbitstem\SEO\Redirects\Repository::hash( $source ); if ( '' === $source || $hash === $rule['source_hash'] ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( ! $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE source_hash = %s', $table, $hash ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( $table, array( 'source' => $source, 'source_hash' => $hash, ), array( 'id' => (int) $rule['id'] ), array( '%s', '%s' ), array( '%d' ) ); } } delete_option( 'orbitstem_seo_regex_redirects' ); } }