table_name = $wpdb->prefix . 'accessibility_audio_tts_listening_stats'; $this->daily = $wpdb->prefix . 'accessibility_audio_tts_listening_daily'; $this->limits = $wpdb->prefix . 'accessibility_audio_tts_listening_limits'; $this->maybe_create_table(); if ( ! self::$registered ) { self::$registered = true; if ( did_action( 'init' ) ) { $this->initialize_privacy(); } else { add_action( 'init', array( $this, 'initialize_privacy' ) ); } add_action( self::MIGRATE_HOOK, array( $this, 'migrate_legacy_batch' ) ); add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_expired' ) ); add_filter( 'wp_privacy_personal_data_exporters', array( $this, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( $this, 'register_eraser' ) ); } } private function maybe_create_table(): void { global $wpdb; if ( '4' === get_option( 'accessibility_audio_tts_listening_schema_version', '' ) ) { return; } require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $collation = $wpdb->get_charset_collate(); // The old table is retained only as a migration/export queue. New events never enter it. dbDelta( "CREATE TABLE {$this->table_name} ( id bigint(20) NOT NULL AUTO_INCREMENT, post_id bigint(20) NOT NULL, session_id varchar(64) NOT NULL, user_id bigint(20) DEFAULT NULL, listen_duration int(11) NOT NULL, audio_duration int(11) DEFAULT NULL, completion_percentage decimal(5,2) DEFAULT NULL, ip_address varchar(45) DEFAULT NULL, user_agent text DEFAULT NULL, event_key varchar(64) DEFAULT NULL, listen_date datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY post_id (post_id), KEY user_id (user_id), UNIQUE KEY event_key (event_key) ) ENGINE=InnoDB {$collation};" ); dbDelta( "CREATE TABLE {$this->daily} ( post_id bigint(20) NOT NULL, stat_date date NOT NULL, audio_requests_count bigint(20) NOT NULL DEFAULT 0, listens_count bigint(20) NOT NULL DEFAULT 0, active_listens bigint(20) NOT NULL DEFAULT 0, duration_sum bigint(20) NOT NULL DEFAULT 0, completion_sum decimal(20,2) NOT NULL DEFAULT 0, completion_count bigint(20) NOT NULL DEFAULT 0, max_duration int(11) NOT NULL DEFAULT 0, high_completion_listens bigint(20) NOT NULL DEFAULT 0, PRIMARY KEY (post_id,stat_date), KEY stat_date (stat_date) ) ENGINE=InnoDB {$collation};" ); dbDelta( "CREATE TABLE {$this->limits} ( bucket_key varchar(64) NOT NULL, hits int(11) NOT NULL DEFAULT 0, expires bigint(20) NOT NULL, post_id bigint(20) DEFAULT NULL, stat_date date DEFAULT NULL, listen_duration int(11) NOT NULL DEFAULT 0, audio_duration int(11) NOT NULL DEFAULT 0, PRIMARY KEY (bucket_key), KEY expires (expires) ) ENGINE=InnoDB {$collation};" ); foreach ( array( $this->table_name, $this->daily, $this->limits ) as $table ) { if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) { return; } } // A failed ALTER must remain retryable rather than declaring the upgrade complete. $wpdb->query( "SELECT audio_requests_count FROM {$this->daily} LIMIT 0" ); if ( $wpdb->last_error ) { return; } $wpdb->query( "SELECT post_id,stat_date,listen_duration,audio_duration FROM {$this->limits} LIMIT 0" ); if ( $wpdb->last_error ) { return; } update_option( 'accessibility_audio_tts_listening_schema_version', '4', false ); } public function initialize_privacy() { if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) { wp_schedule_event( time() + 60, 'hourly', self::CLEANUP_HOOK ); } // An upgrade anonymises one small batch per request; cron continues without visitors. if ( '1' !== get_option( 'accessibility_audio_tts_analytics_anonymized', '' ) ) { $this->migrate_legacy_batch(); } } public function privacy_status() { global $wpdb; $remaining = $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table_name}" ); $error = $wpdb->last_error ? __( 'The historical analytics migration status could not be read.', 'accessibility-audio-tts-text-to-speech-for-articles' ) : get_option( self::ERROR_OPTION, '' ); return array( 'complete' => ! $error && null !== $remaining && 0 === (int) $remaining, 'pending_rows' => $wpdb->last_error ? null : (int) $remaining, 'error' => $error ); } private function query( $sql ) { global $wpdb; $result = $wpdb->query( $sql ); if ( false === $result || $wpdb->last_error ) { throw new \RuntimeException( 'The analytics database update failed; historical data has not been declared anonymised.' ); } return $result; } /** Cross-table anonymisation must never run on a non-transactional legacy engine. */ private function require_transactional_tables() { global $wpdb; if ( false !== stripos( get_class( $wpdb ), 'sqlite' ) ) { return; } foreach ( array( $this->table_name, $this->daily, $this->limits ) as $table ) { $status = $wpdb->get_row( $wpdb->prepare( 'SHOW TABLE STATUS WHERE Name=%s', $table ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $status ) || 'innodb' !== strtolower( $status['Engine'] ?? '' ) ) { throw new \RuntimeException( 'Analytics requires transactional InnoDB tables. Ask the site administrator to check the analytics table engines before retrying anonymisation.' ); } } } private function event_bucket( $key ) { return hash( 'sha256', 'aat-listen-event:' . $key ); } /** A short-lived per-play token deduplicates retries; it is not a visitor ID. */ public function event_exists( $key ) { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT bucket_key FROM {$this->limits} WHERE bucket_key=%s AND expires>%d", $this->event_bucket( $key ), time() ) ); } /** * Count only delivery attempts for public audio, without reading visitor data. * * The caller has already opened the file and validated the byte range. These * totals are deliberately separate from consented listens and duration. */ public function record_anonymous_audio_request( $post_id ) { global $wpdb; if ( 'anonymous' !== Privacy_Settings::get( 'analytics_mode' ) || \Accessibility_Audio_TTS\Core\Audio_Access::is_restricted( get_post( $post_id ) ) ) { return false; } $previous_suppression = $wpdb->suppress_errors( true ); try { $day = current_time( 'Y-m-d' ); $this->query( $wpdb->prepare( "INSERT IGNORE INTO {$this->daily} (post_id,stat_date) VALUES (%d,%s)", $post_id, $day ) ); $result = $this->query( $wpdb->prepare( "UPDATE {$this->daily} SET audio_requests_count=audio_requests_count+1 WHERE post_id=%d AND stat_date=%s AND audio_requests_count<9223372036854775807", $post_id, $day ) ); if ( 1 === $result ) { $this->clear_cache( $post_id ); } return 1 === $result; } catch ( \Throwable $error ) { // Optional measurement must never stop an otherwise valid audio response. return false; } finally { $wpdb->suppress_errors( $previous_suppression ); } } /** Consented cumulative snapshots add only the progress since the previous snapshot. */ public function record_listening_event( $post_id, $listen_duration, $audio_duration = null, $event_key = null, $consent = false ) { global $wpdb; if ( 'consented' !== Privacy_Settings::get( 'analytics_mode' ) || true !== $consent || $post_id < 1 || $listen_duration < 5 || $listen_duration > 21600 || \Accessibility_Audio_TTS\Core\Audio_Access::is_restricted( get_post( $post_id ) ) ) { return false; } $listen_duration = (int) $listen_duration; $audio_duration = max( 0, (int) $audio_duration ); $key = $event_key ?? wp_generate_uuid4(); try { $this->require_transactional_tables(); // Release expiry-range locks before inserting per-play keys on InnoDB. $this->query( $wpdb->prepare( "DELETE FROM {$this->limits} WHERE expires<=%d", time() ) ); $this->query( 'START TRANSACTION' ); $bucket = $this->event_bucket( $key ); $insert = $this->query( $wpdb->prepare( "INSERT IGNORE INTO {$this->limits} (bucket_key,hits,expires,post_id,stat_date) VALUES (%s,1,%d,%d,%s)", $bucket, time() + DAY_IN_SECONDS, $post_id, current_time( 'Y-m-d' ) ) ); // The INSERT acquires SQLite's writer lock; InnoDB needs a locking read. $locking = false === stripos( get_class( $wpdb ), 'sqlite' ) ? ' FOR UPDATE' : ''; $previous = $wpdb->get_row( $wpdb->prepare( "SELECT post_id,stat_date,listen_duration,audio_duration FROM {$this->limits} WHERE bucket_key=%s", $bucket ) . $locking, ARRAY_A ); if ( $wpdb->last_error || ! is_array( $previous ) ) { throw new \RuntimeException( 'The listening progress state could not be read.' ); } // Pre-upgrade tokens have no post/progress state: keep their old deduplication. if ( null === $previous['post_id'] || (int) $previous['post_id'] !== (int) $post_id || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/D', (string) $previous['stat_date'] ) ) { $this->query( 'ROLLBACK' ); return false; } $old_duration = (int) $previous['listen_duration']; $old_audio = (int) $previous['audio_duration']; if ( $old_audio > 0 && $audio_duration !== $old_audio ) { $this->query( 'ROLLBACK' ); return false; } if ( $listen_duration <= $old_duration && ( $old_audio > 0 || 0 === $audio_duration ) ) { $this->query( 'ROLLBACK' ); return false; } if ( $listen_duration < $old_duration ) { $this->query( 'ROLLBACK' ); return false; } $this->add_progress_totals( (int) $post_id, $previous['stat_date'], $old_duration, $old_audio, $listen_duration, $audio_duration, 1 === $insert ); $this->query( $wpdb->prepare( "UPDATE {$this->limits} SET listen_duration=%d,audio_duration=%d WHERE bucket_key=%s", $listen_duration, $audio_duration, $bucket ) ); $this->query( 'COMMIT' ); $this->clear_cache( $post_id ); return 1; } catch ( \Throwable $error ) { $wpdb->query( 'ROLLBACK' ); update_option( self::ERROR_OPTION, $error->getMessage(), false ); return false; } } private function add_progress_totals( $post_id, $day, $old_duration, $old_audio, $duration, $audio, $new_play ) { global $wpdb; $was_active = $old_duration >= 10 ? 1 : 0; $is_active = $duration >= 10 ? 1 : 0; $was_known = $was_active && $old_audio > 0 ? 1 : 0; $is_known = $is_active && $audio > 0 ? 1 : 0; $before = $was_known ? round( min( 100, 100 * $old_duration / $old_audio ), 2 ) : 0; $after = $is_known ? round( min( 100, 100 * $duration / $audio ), 2 ) : 0; $this->query( $wpdb->prepare( "INSERT IGNORE INTO {$this->daily} (post_id,stat_date) VALUES (%d,%s)", $post_id, $day ) ); $this->query( $wpdb->prepare( "UPDATE {$this->daily} SET listens_count=listens_count+%d,active_listens=active_listens+%d,duration_sum=duration_sum+%d,completion_sum=completion_sum+%f,completion_count=completion_count+%d,max_duration=CASE WHEN max_duration<%d THEN %d ELSE max_duration END,high_completion_listens=high_completion_listens+%d WHERE post_id=%d AND stat_date=%s", $new_play ? 1 : 0, $is_active - $was_active, ( $is_active ? $duration : 0 ) - ( $was_active ? $old_duration : 0 ), $after - $before, $is_known - $was_known, $is_active ? $duration : 0, $is_active ? $duration : 0, ( $after >= 80 ? 1 : 0 ) - ( $before >= 80 ? 1 : 0 ), $post_id, $day ) ); } private function add_totals( $post_id, $day, $rows ) { global $wpdb; $count = count( $rows ); $active = 0; $duration = 0; $completion = 0; $known = 0; $max = 0; $high = 0; foreach ( $rows as $row ) { if ( (int) $row['listen_duration'] < 10 ) { continue; } ++$active; $seconds = (int) $row['listen_duration']; $duration += $seconds; $max = max( $max, $seconds ); if ( null !== $row['completion_percentage'] ) { ++$known; $percent = (float) $row['completion_percentage']; $completion += $percent; if ( $percent >= 80 ) { ++$high; } } } $this->query( $wpdb->prepare( "INSERT IGNORE INTO {$this->daily} (post_id,stat_date) VALUES (%d,%s)", $post_id, $day ) ); $this->query( $wpdb->prepare( "UPDATE {$this->daily} SET listens_count=listens_count+%d,active_listens=active_listens+%d,duration_sum=duration_sum+%d,completion_sum=completion_sum+%f,completion_count=completion_count+%d,max_duration=CASE WHEN max_duration<%d THEN %d ELSE max_duration END,high_completion_listens=high_completion_listens+%d WHERE post_id=%d AND stat_date=%s", $count, $active, $duration, $completion, $known, $max, $max, $high, $post_id, $day ) ); } private function acquire_migration_lock() { global $wpdb; $value = wp_json_encode( array( 'token' => bin2hex( random_bytes( 24 ) ), 'expires' => time() + 60 ) ); $result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name,option_value,autoload) VALUES (%s,%s,'no')", self::LOCK_OPTION, $value ) ); if ( 1 !== $result ) { $old = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", self::LOCK_OPTION ) ); $data = json_decode( (string) $old, true ); if ( ! is_array( $data ) || ( $data['expires'] ?? PHP_INT_MAX ) > time() || 1 !== $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value=%s WHERE option_name=%s AND BINARY option_value=%s", $value, self::LOCK_OPTION, $old ) ) ) { return false; } } wp_cache_delete( self::LOCK_OPTION, 'options' ); return $value; } private function release_migration_lock( $value ) { global $wpdb; $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name=%s AND BINARY option_value=%s", self::LOCK_OPTION, $value ) ); wp_cache_delete( self::LOCK_OPTION, 'options' ); } /** Aggregate and remove the same legacy rows in one transaction; retries never add twice. */ public function migrate_legacy_batch( $user_id = null ) { global $wpdb; $lock = $this->acquire_migration_lock(); if ( ! $lock ) { return false; } try { $this->require_transactional_tables(); $this->query( 'START TRANSACTION' ); $where = null === $user_id ? '' : $wpdb->prepare( ' WHERE user_id=%d', $user_id ); $rows = $wpdb->get_results( "SELECT id,post_id,listen_duration,completion_percentage,listen_date FROM {$this->table_name}{$where} ORDER BY id ASC LIMIT 100", ARRAY_A ); if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new \RuntimeException( 'Historical analytics could not be read. Retry anonymisation after checking the database.' ); } $groups = array(); $ids = array(); $posts = array(); foreach ( $rows as $row ) { $day = substr( $row['listen_date'], 0, 10 ); if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $day ) ) { throw new \RuntimeException( 'A historical analytics date is invalid. Review the affected data before retrying.' ); } $key = $row['post_id'] . ':' . $day; if ( ! isset( $groups[ $key ] ) ) { $groups[ $key ] = array( 'post' => (int) $row['post_id'], 'day' => $day, 'rows' => array() ); } $groups[ $key ]['rows'][] = $row; $ids[] = (int) $row['id']; $posts[] = (int) $row['post_id']; } foreach ( $groups as $group ) { $this->add_totals( $group['post'], $group['day'], $group['rows'] ); } $old_lock = json_decode( $lock, true ); if ( ( $old_lock['expires'] ?? 0 ) <= time() ) { throw new \RuntimeException( 'Historical analytics migration ownership expired. Retry the bounded batch.' ); } $next_lock = wp_json_encode( array( 'token' => $old_lock['token'], 'expires' => time() + 60, 'revision' => 1 ) ); // A conditional write is a current locking read on MySQL, unlike a stale snapshot SELECT. if ( 1 !== $this->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value=%s WHERE option_name=%s AND BINARY option_value=%s", $next_lock, self::LOCK_OPTION, $lock ) ) ) { throw new \RuntimeException( 'Historical analytics migration changed owner. Retry the bounded batch.' ); } if ( $ids ) { $this->query( "DELETE FROM {$this->table_name} WHERE id IN (" . implode( ',', $ids ) . ')' ); } $this->query( 'COMMIT' ); $lock = $next_lock; foreach ( array_unique( $posts ) as $id ) { $this->clear_cache( $id ); } delete_option( self::ERROR_OPTION ); $pending = $wpdb->get_var( "SELECT id FROM {$this->table_name} LIMIT 1" ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'The historical analytics completion check failed.' ); } if ( null === $pending ) { update_option( 'accessibility_audio_tts_analytics_anonymized', '1', false ); wp_clear_scheduled_hook( self::MIGRATE_HOOK ); } elseif ( ! wp_next_scheduled( self::MIGRATE_HOOK ) ) { wp_schedule_single_event( time() + 10, self::MIGRATE_HOOK ); } return count( $rows ); } catch ( \Throwable $error ) { $wpdb->query( 'ROLLBACK' ); delete_option( 'accessibility_audio_tts_analytics_anonymized' ); update_option( self::ERROR_OPTION, $error->getMessage(), false ); if ( ! wp_next_scheduled( self::MIGRATE_HOOK ) ) { wp_schedule_single_event( time() + 60, self::MIGRATE_HOOK ); } return false; } finally { $this->release_migration_lock( $lock ); } } public function cleanup_expired() { global $wpdb; $days = (int) Privacy_Settings::get( 'analytics_retention_days' ); $cutoff = wp_date( 'Y-m-d', time() - $days * DAY_IN_SECONDS ); try { $this->query( $wpdb->prepare( "DELETE FROM {$this->daily} WHERE stat_date<%s", $cutoff ) ); $this->query( $wpdb->prepare( "DELETE FROM {$this->limits} WHERE expires<=%d", time() ) ); return $this->migrate_legacy_batch(); } catch ( \Throwable $error ) { update_option( self::ERROR_OPTION, $error->getMessage(), false ); return false; } } public function reserve_request_slot( $key, $expires, $limit = 60 ) { global $wpdb; $wpdb->query( $wpdb->prepare( "DELETE FROM {$this->limits} WHERE expires<=%d", time() ) ); $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$this->limits} (bucket_key,hits,expires) VALUES (%s,0,%d)", $key, $expires ) ); return 1 === $wpdb->query( $wpdb->prepare( "UPDATE {$this->limits} SET hits=hits+1 WHERE bucket_key=%s AND hits<%d", $key, $limit ) ); } private function clear_cache( $post_id ) { foreach ( array( 'accessibility_audio_tts_total_active_listens', 'accessibility_audio_tts_recent_active_listens_24h', 'accessibility_audio_tts_listening_trend_7d', 'accessibility_audio_tts_post_listening_stats_' . absint( $post_id ) ) as $key ) { wp_cache_delete( $key, 'accessibility_audio_tts' ); } } public function get_total_active_listens() { global $wpdb; return (int) $wpdb->get_var( "SELECT COALESCE(SUM(active_listens),0) FROM {$this->daily}" ); } public function get_total_audio_requests() { global $wpdb; return (int) $wpdb->get_var( "SELECT COALESCE(SUM(audio_requests_count),0) FROM {$this->daily}" ); } public function get_recent_audio_requests() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(audio_requests_count),0) FROM {$this->daily} WHERE stat_date=%s", current_time( 'Y-m-d' ) ) ); } /** Day-level aggregation deliberately cannot reconstruct rolling 24-hour histories. */ public function get_recent_active_listens() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(active_listens),0) FROM {$this->daily} WHERE stat_date=%s", current_time( 'Y-m-d' ) ) ); } public function get_post_listening_stats( $post_id ) { global $wpdb; $stats = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(audio_requests_count) AS requests,SUM(active_listens) AS n,SUM(duration_sum) AS duration,SUM(completion_sum) AS completion,SUM(completion_count) AS known,MAX(max_duration) AS max_duration,SUM(high_completion_listens) AS high FROM {$this->daily} WHERE post_id=%d", $post_id ), ARRAY_A ); return array( 'audio_requests' => (int) ( $stats['requests'] ?? 0 ), 'total_listens' => (int) ( $stats['n'] ?? 0 ), 'unique_listeners' => null, 'avg_duration' => ! empty( $stats['n'] ) ? round( $stats['duration'] / $stats['n'], 1 ) : 0, 'avg_completion' => ! empty( $stats['known'] ) ? round( $stats['completion'] / $stats['known'], 1 ) : 0, 'max_duration' => (int) ( $stats['max_duration'] ?? 0 ), 'high_completion_listens' => (int) ( $stats['high'] ?? 0 ), 'privacy_mode' => 'daily_aggregate' ); } public function get_listening_trend() { return $this->get_daily_trend( 'active_listens' ); } public function get_audio_request_trend() { return $this->get_daily_trend( 'audio_requests_count' ); } private function get_daily_trend( $column ) { global $wpdb; $week = wp_date( 'Y-m-d', time() - 7 * DAY_IN_SECONDS ); $before = wp_date( 'Y-m-d', time() - 14 * DAY_IN_SECONDS ); $current = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM({$column}),0) FROM {$this->daily} WHERE stat_date>=%s", $week ) ); $previous = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM({$column}),0) FROM {$this->daily} WHERE stat_date>=%s AND stat_date<%s", $before, $week ) ); return array( 'current_week' => $current, 'previous_week' => $previous, 'change_percentage' => $previous > 0 ? round( ( ( $current - $previous ) / $previous ) * 100, 1 ) : ( $current > 0 ? 100 : 0 ), 'trend' => $current > $previous ? 'up' : ( $current < $previous ? 'down' : 'stable' ) ); } public function register_exporter( $exporters ) { $exporters['accessibility-audio-tts'] = array( 'exporter_friendly_name' => __( 'Historical audio listening data', 'accessibility-audio-tts-text-to-speech-for-articles' ), 'callback' => array( $this, 'export_legacy_data' ) ); return $exporters; } public function register_eraser( $erasers ) { $erasers['accessibility-audio-tts'] = array( 'eraser_friendly_name' => __( 'Historical audio listening data', 'accessibility-audio-tts-text-to-speech-for-articles' ), 'callback' => array( $this, 'erase_legacy_data' ) ); return $erasers; } public function export_legacy_data( $email, $page = 1 ) { global $wpdb; $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'data' => array(), 'done' => true ); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$this->table_name} WHERE user_id=%d ORDER BY id LIMIT 100 OFFSET %d", $user->ID, max( 0, (int) $page - 1 ) * 100 ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $rows ) ) { return new \WP_Error( 'aat_privacy_export_failed', __( 'Historical analytics could not be exported. Please retry after checking the database.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $data = array(); foreach ( $rows as $row ) { $item = array(); foreach ( array( 'post_id','listen_duration','audio_duration','completion_percentage','listen_date','ip_address','user_agent','session_id','user_id','event_key' ) as $field ) { $item[] = array( 'name' => $field, 'value' => (string) ( $row[ $field ] ?? '' ) ); } $data[] = array( 'group_id' => 'accessibility-audio-tts', 'group_label' => __( 'Historical audio listening data', 'accessibility-audio-tts-text-to-speech-for-articles' ), 'item_id' => 'aat-listen-' . $row['id'], 'data' => $item ); } return array( 'data' => $data, 'done' => count( (array) $rows ) < 100 ); } public function erase_legacy_data( $email, $page = 1 ) { global $wpdb; $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true ); } $removed = $this->migrate_legacy_batch( (int) $user->ID ); $remaining = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$this->table_name} WHERE user_id=%d LIMIT 1", $user->ID ) ); $failed = false === $removed || $wpdb->last_error; return array( 'items_removed' => $removed > 0, 'items_retained' => (bool) $failed, 'messages' => $failed ? array( __( 'Historical analytics could not be anonymised. Please retry after checking the database.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ) : array(), 'done' => ! $failed && null === $remaining ); } }