new name. */ const OPTIONS = array( 'af_templates' => 'accessible_forms_templates', 'af_brand_presets' => 'accessible_forms_brand_presets', 'af_last_cleanup' => 'accessible_forms_last_cleanup', 'af_extension_failure' => 'accessible_forms_extension_failure', 'af_placement_generation' => 'accessible_forms_placement_generation', ); /** Form post meta: old key => new key. */ const POST_META = array( '_af_schema' => '_accessible_forms_schema', '_af_working_schema' => '_accessible_forms_working_schema', '_af_versions' => '_accessible_forms_versions', '_af_entry_users' => '_accessible_forms_entry_users', ); /** Capabilities: old => new. */ const CAPS = array( 'read_af_entries' => 'read_accessible_form_entries', 'manage_af_entries' => 'manage_accessible_form_entries', 'export_af_entries' => 'export_accessible_form_entries', 'delete_af_entries' => 'delete_accessible_form_entries', 'send_af_notifications' => 'send_accessible_form_notifications', 'manage_af_settings' => 'manage_accessible_forms_settings', ); /** Roles: old => new. */ const ROLES = array( 'af_form_manager' => 'accessible_forms_manager', 'af_entry_reviewer' => 'accessible_forms_reviewer', ); /** Cron hook that indexes older entries a batch at a time. */ const SEARCH_HOOK = 'accessible_forms_search_index'; /** Option holding the last entry ID indexed, so the upgrade can resume. */ const SEARCH_CURSOR = 'accessible_forms_search_cursor'; /** Entries indexed per batch. */ const SEARCH_BATCH = 500; /** * Run the migrations needed to move from a stored version. * * @param int $from Stored database version; 0 for a new installation. */ public static function run( $from ) { if ( $from > 0 && $from < 6 ) { self::rename_prefix(); } // The table has just been created or upgraded, so the column exists. self::prepare_search_index(); } /** * Register the search index upgrade. It is independent of the stored * database version, so it also runs on sites upgraded without activation. */ public static function register() { add_action( 'init', array( self::class, 'maybe_prepare_search_index' ), 5 ); add_action( self::SEARCH_HOOK, array( self::class, 'index_search_batch' ) ); } /** * Add the search column on the first request after an update, then hand * the backfill to cron. Retries at most hourly if the column cannot be added. */ public static function maybe_prepare_search_index() { $state = (int) get_option( Entries::SEARCH_OPTION, 0 ); if ( $state >= 2 ) { return; } if ( $state >= 1 ) { self::schedule_search_batch(); return; } if ( get_transient( 'accessible_forms_search_retry' ) ) { return; } set_transient( 'accessible_forms_search_retry', 1, HOUR_IN_SECONDS ); Entries::install(); self::prepare_search_index(); } /** * Record that the column exists and start indexing older entries. */ private static function prepare_search_index() { global $wpdb; $state = (int) get_option( Entries::SEARCH_OPTION, 0 ); if ( $state >= 1 ) { // Once every entry is indexed there is nothing left to schedule. if ( 1 === $state ) { self::schedule_search_batch(); } return; } $columns = $wpdb->get_col( $wpdb->prepare( 'DESCRIBE %i', Entries::table_name() ) ); if ( ! in_array( 'search_text', (array) $columns, true ) ) { return; } update_option( Entries::SEARCH_OPTION, 1, true ); update_option( self::SEARCH_CURSOR, 0, false ); delete_transient( 'accessible_forms_search_retry' ); self::schedule_search_batch(); } /** Queue the next batch unless one is already queued. */ private static function schedule_search_batch() { if ( ! wp_next_scheduled( self::SEARCH_HOOK ) ) { wp_schedule_single_event( time() + 30, self::SEARCH_HOOK ); } } /** * Version 8: store answers without escaping non-Latin text and slashes, and * fill the plain search text. Works through the table by ID, a bounded * batch per run, and resumes from the saved cursor if interrupted. * * @return int Rows processed in this batch. */ public static function index_search_batch() { global $wpdb; if ( 1 !== (int) get_option( Entries::SEARCH_OPTION, 0 ) ) { return 0; } $lease = Locks::acquire( 'search_index', 5 * MINUTE_IN_SECONDS ); if ( ! $lease ) { return 0; } try { $cursor = (int) get_option( self::SEARCH_CURSOR, 0 ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, fields, snapshot, notes FROM %i WHERE id > %d ORDER BY id ASC LIMIT %d', Entries::table_name(), $cursor, self::SEARCH_BATCH ), ARRAY_A ); foreach ( $rows ?: array() as $row ) { Entries::reindex_row( $row ); $cursor = (int) $row['id']; } update_option( self::SEARCH_CURSOR, $cursor, false ); if ( count( $rows ?: array() ) < self::SEARCH_BATCH ) { update_option( Entries::SEARCH_OPTION, 2, true ); delete_option( self::SEARCH_CURSOR ); } else { wp_schedule_single_event( time() + 30, self::SEARCH_HOOK ); } return count( $rows ?: array() ); } finally { Locks::release( $lease ); } } /** Version 6: rename short-prefixed options, meta, capabilities and roles. */ private static function rename_prefix() { global $wpdb; foreach ( self::OPTIONS as $old => $new ) { $value = get_option( $old, null ); if ( null === $value ) { continue; } if ( null === get_option( $new, null ) ) { add_option( $new, $value, '', false ); } delete_option( $old ); } foreach ( self::POST_META as $old => $new ) { $wpdb->query( $wpdb->prepare( 'UPDATE %i SET meta_key = %s WHERE meta_key = %s AND post_id IN ( SELECT ID FROM %i WHERE post_type = %s )', $wpdb->postmeta, $new, $old, $wpdb->posts, 'accessible_form' ) ); } // Saved inbox views are stored per user and per site: _af_saved_views_{blog_id}. $old_views = '_af_saved_views_' . get_current_blog_id(); $wpdb->query( $wpdb->prepare( 'UPDATE %i SET meta_key = %s WHERE meta_key = %s', $wpdb->usermeta, '_accessible_forms_saved_views_' . get_current_blog_id(), $old_views ) ); // Short-lived leases and one-time receipts are simply discarded; they are // recreated under the new names when needed. foreach ( array( 'af_lock_', 'af_altcha_used_' ) as $prefix ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( $prefix ) . '%' ) ); } self::rename_capabilities(); $form_ids = get_posts( array( 'post_type' => 'accessible_form', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', ) ); foreach ( $form_ids as $form_id ) { wp_cache_delete( $form_id, 'post_meta' ); } } /** Carry each role's grants and each user's role over to the new names. */ private static function rename_capabilities() { foreach ( wp_roles()->role_objects as $role ) { foreach ( self::CAPS as $old => $new ) { if ( isset( $role->capabilities[ $old ] ) ) { $role->add_cap( $new, (bool) $role->capabilities[ $old ] ); $role->remove_cap( $old ); } } } foreach ( self::ROLES as $old => $new ) { $old_role = get_role( $old ); if ( ! $old_role ) { continue; } $caps = array(); foreach ( $old_role->capabilities as $cap => $grant ) { $caps[ self::CAPS[ $cap ] ?? $cap ] = $grant; } $new_role = get_role( $new ); if ( $new_role ) { // Keep any customization made to the old role. foreach ( array_keys( $new_role->capabilities ) as $cap ) { $new_role->remove_cap( $cap ); } foreach ( $caps as $cap => $grant ) { $new_role->add_cap( $cap, (bool) $grant ); } } else { add_role( $new, wp_roles()->role_names[ $old ] ?? $new, $caps ); } do { $users = get_users( array( 'role' => $old, 'number' => 200, 'fields' => 'all', ) ); foreach ( $users as $user ) { $user->add_role( $new ); $user->remove_role( $old ); } } while ( count( $users ) === 200 ); remove_role( $old ); } } }