14 * 60 ) { return ''; } $value = sprintf( '%s%02d:%02d', $match[1], (int) floor( $minutes / 60 ), $minutes % 60 ); } return in_array( $value, \DateTimeZone::listIdentifiers( \DateTimeZone::ALL_WITH_BC ), true ) || preg_match( '/^[+-](?:0\d|1[0-3]):[0-5]\d$|^[+-]14:00$/D', $value ) ? $value : ''; } public static function register() { add_action( self::HOOK, array( self::class, 'batch' ) ); add_action( 'init', array( self::class, 'resume' ), 6 ); } public static function pending() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE time_version <> %d', Entries::table_name(), EntryTime::VERSION ) ); } /** Rollback must cover both converted entries and the option checkpoint. */ private static function transactions_ready() { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS WHERE Name IN (%s, %s)', Entries::table_name(), $wpdb->options ), ARRAY_A ); if ( $wpdb->last_error || 2 !== count( $rows ?: array() ) ) { return false; } foreach ( $rows as $row ) { if ( ! in_array( strtolower( (string) $row['Engine'] ), array( 'innodb', 'xtradb' ), true ) ) { return false; } } return true; } public static function start( $zone, $policy, $confirmed, $form_id = 0 ) { if ( ! current_user_can( 'manage_accessible_forms_settings' ) || ! $confirmed ) { return new \WP_Error( 'confirmation', __( 'Confirm the original time zone before converting historical dates.', 'accessible-forms-by-accessible-org' ) ); } $zone = self::zone_name( $zone ); if ( ! $zone || ! in_array( $policy, array( 'preserve', 'earlier', 'later' ), true ) ) { return new \WP_Error( 'timezone', __( 'Choose a valid original time zone and repeated-hour policy.', 'accessible-forms-by-accessible-org' ) ); } $form_id = absint( $form_id ); if ( $form_id && ( 'accessible_form' !== get_post_type( $form_id ) || ! Capabilities::form( $form_id, 'manage_accessible_form_entries' ) ) ) { return new \WP_Error( 'form', __( 'Choose a form whose entries you can manage.', 'accessible-forms-by-accessible-org' ) ); } if ( ! self::transactions_ready() ) { return new \WP_Error( 'transactions', __( 'Historical date conversion requires transactional entry and options tables. Ask your hosting provider to enable InnoDB; dates are unchanged.', 'accessible-forms-by-accessible-org' ) ); } $lease = Locks::acquire( 'time_migration', 5 * MINUTE_IN_SECONDS ); if ( ! $lease ) { return new \WP_Error( 'busy', __( 'A historical date batch is running. Try again shortly.', 'accessible-forms-by-accessible-org' ) ); } try { self::fresh_state(); $previous = get_option( self::OPTION, array() ); if ( 'running' === ( $previous['status'] ?? '' ) ) { return new \WP_Error( 'busy', __( 'Finish the current historical date conversion first.', 'accessible-forms-by-accessible-org' ) ); } global $wpdb; $state = array( 'version' => 1, 'zone' => $zone, 'policy' => $policy, 'form_id' => $form_id, 'cursor' => 0, 'max_id' => (int) $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(id) FROM %i', Entries::table_name() ) ), 'converted' => 0, 'unconfirmed' => 0, 'status' => 'running' ); if ( ! Persistence::option( self::OPTION, $state, false ) ) { return new \WP_Error( 'storage', __( 'The conversion could not be saved. Historical dates are unchanged.', 'accessible-forms-by-accessible-org' ) ); } self::resume(); Audit::record( 'historical_dates_confirmed', $form_id ); return true; } finally { Locks::release( $lease ); } } public static function resume() { $state = get_option( self::OPTION, array() ); if ( 'running' === ( $state['status'] ?? '' ) && ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + 30, self::HOOK ); } } /** A request may have read this option before another worker advanced it. */ private static function fresh_state() { wp_cache_delete( self::OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); } public static function batch() { global $wpdb; $transaction = false; $lease = Locks::acquire( 'time_migration', 5 * MINUTE_IN_SECONDS ); if ( ! $lease ) { return 0; } try { self::fresh_state(); $state = get_option( self::OPTION, array() ); if ( 'running' !== ( $state['status'] ?? '' ) ) { return 0; } if ( ! self::transactions_ready() ) { self::resume(); return 0; } try { $zone = new \DateTimeZone( $state['zone'] ); } catch ( \Exception $error ) { return 0; } $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE id > %d AND id <= %d AND time_version <> %d AND (%d = 0 OR form_id = %d) ORDER BY id LIMIT %d', Entries::table_name(), $state['cursor'], $state['max_id'], EntryTime::VERSION, $state['form_id'], $state['form_id'], self::BATCH ) ); if ( $wpdb->last_error ) { self::resume(); return 0; } if ( false === $wpdb->query( 'BEGIN' ) ) { self::resume(); return 0; } $transaction = true; $failed = false; $processed = 0; foreach ( $ids as $id ) { $row_lease = Locks::acquire( 'entry:' . (int) $id ); if ( ! $row_lease ) { $failed = true; break; } try { $row = $wpdb->get_row( $wpdb->prepare( 'SELECT id, created_at, resolved_at, first_reply_at, time_version, time_original, retention_days, expires_at FROM %i WHERE id = %d FOR UPDATE', Entries::table_name(), $id ), ARRAY_A ); if ( $wpdb->last_error ) { $failed = true; break; } if ( $row ) { $mask = (int) $row['time_version']; $data = array(); $original = json_decode( (string) ( $row['time_original'] ?? '' ), true ) ?: array(); foreach ( EntryTime::FIELDS as $column => $bit ) { if ( $mask & $bit ) { continue; } if ( ! $row[ $column ] && 'created_at' !== $column ) { $mask |= $bit; continue; } $utc = EntryTime::historical( $row[ $column ], $zone, $state['policy'] ); if ( null === $utc ) { continue; } $original[ $column ] = array( 'value' => $row[ $column ], 'zone' => $state['zone'], 'policy' => $state['policy'] ); $data[ $column ] = $utc; $mask |= $bit; } // Imported dates acquire a retention deadline only after their timezone is confirmed. if ( isset( $data['created_at'] ) && empty( $row['expires_at'] ) && (int) $row['retention_days'] > 0 ) { $data['expires_at'] = EntryTime::utc( EntryTime::parse( $data['created_at'] ) + (int) $row['retention_days'] * DAY_IN_SECONDS ); } $data['time_version'] = $mask; $data['time_original'] = $original ? wp_json_encode( $original ) : null; if ( false === $wpdb->update( Entries::table_name(), $data, array( 'id' => (int) $id, 'time_version' => (int) $row['time_version'] ) ) ) { $failed = true; break; } ++$state[ EntryTime::VERSION === $mask ? 'converted' : 'unconfirmed' ]; } $state['cursor'] = (int) $id; ++$processed; } finally { Locks::release( $row_lease ); } } if ( count( $ids ) < self::BATCH && $processed === count( $ids ) ) { $state['status'] = $state['unconfirmed'] ? 'review' : 'complete'; } // Conversion and checkpoint share a transaction: failure cannot lose originals, // advance the cursor or inflate progress counts. Per-column bits prevent replays. if ( $failed || ! Persistence::option( self::OPTION, $state, false ) || false === $wpdb->query( 'COMMIT' ) ) { $wpdb->query( 'ROLLBACK' ); $transaction = false; wp_cache_delete( self::OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); self::resume(); return 0; } $transaction = false; self::resume(); return $processed; } finally { if ( $transaction ) { $wpdb->query( 'ROLLBACK' ); wp_cache_delete( self::OPTION, 'options' ); wp_cache_delete( 'alloptions', 'options' ); } Locks::release( $lease ); } } }