detect(); } /** * Plugin activated. * * @param mixed $plugin Plugin basename. */ public function on_activated( $plugin ): void { $this->record_plugin( 'activate', (string) $plugin ); } /** * Plugin deactivated. * * @param mixed $plugin Plugin basename. */ public function on_deactivated( $plugin ): void { $this->record_plugin( 'deactivate', (string) $plugin ); } /** * Theme switched. * * @param mixed $name New theme name. */ public function on_switch_theme( $name ): void { $theme = wp_get_theme(); $this->add( array( 'time' => time(), 'kind' => 'theme_switch', 'type' => 'theme', 'slug' => (string) $theme->get_stylesheet(), 'name' => is_string( $name ) && '' !== $name ? $name : (string) $theme->get( 'Name' ), 'from' => null, 'to' => (string) $theme->get( 'Version' ), ) ); } /** * Compare installed versions with the last snapshot and record what changed. * * @return int Number of changes recorded. */ public function detect(): int { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( function_exists( 'wp_clean_plugins_cache' ) ) { wp_clean_plugins_cache( false ); } $current = self::current(); $previous = get_option( self::VERSIONS, null ); update_option( self::VERSIONS, $current, false ); // First run: nothing to compare with yet. if ( ! is_array( $previous ) ) { return 0; } $changes = self::diff( $previous, $current, time() ); foreach ( $changes as $change ) { $this->add( $change ); } return count( $changes ); } /** * Pure comparison of two snapshots. * * @param array $before Previous snapshot. * @param array $after Current snapshot. * @param int $now Time of the change. * @return array */ public static function diff( array $before, array $after, int $now ): array { $changes = array(); foreach ( $after as $key => $item ) { list( $type, $slug ) = self::split( $key ); if ( ! isset( $before[ $key ] ) ) { $changes[] = array( 'time' => $now, 'kind' => 'install', 'type' => $type, 'slug' => $slug, 'name' => $item['name'], 'from' => null, 'to' => $item['version'], ); } elseif ( $before[ $key ]['version'] !== $item['version'] ) { $changes[] = array( 'time' => $now, 'kind' => 'update', 'type' => $type, 'slug' => $slug, 'name' => $item['name'], 'from' => $before[ $key ]['version'], 'to' => $item['version'], ); } } foreach ( $before as $key => $item ) { if ( ! isset( $after[ $key ] ) ) { list( $type, $slug ) = self::split( $key ); $changes[] = array( 'time' => $now, 'kind' => 'delete', 'type' => $type, 'slug' => $slug, 'name' => $item['name'], 'from' => $item['version'], 'to' => null, ); } } return $changes; } /** * Recorded changes, newest first, within the keep window. * * @return array */ public function all(): array { $rows = get_option( self::OPTION, array() ); $out = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { if ( ! is_array( $row ) || ! isset( $row['time'], $row['kind'], $row['type'], $row['slug'] ) ) { continue; } $out[] = array( 'time' => (int) $row['time'], 'kind' => (string) $row['kind'], 'type' => (string) $row['type'], 'slug' => (string) $row['slug'], 'name' => isset( $row['name'] ) ? (string) $row['name'] : (string) $row['slug'], 'from' => isset( $row['from'] ) ? (string) $row['from'] : null, 'to' => isset( $row['to'] ) ? (string) $row['to'] : null, ); } return $out; } /** * Forget all changes and the version snapshot. */ public function clear(): void { delete_option( self::OPTION ); delete_option( self::VERSIONS ); } /** * Installed plugins, themes and core: "type:slug" => name and version. * * @return array */ public static function current(): array { global $wp_version; $out = array( 'core:wordpress' => array( 'name' => 'WordPress', 'version' => (string) $wp_version, ), ); foreach ( get_plugins() as $file => $data ) { $out[ 'plugin:' . self::plugin_slug( (string) $file ) ] = array( 'name' => (string) ( $data['Name'] ?? $file ), 'version' => (string) ( $data['Version'] ?? '' ), ); } foreach ( wp_get_themes() as $stylesheet => $theme ) { $out[ 'theme:' . (string) $stylesheet ] = array( 'name' => (string) $theme->get( 'Name' ), 'version' => (string) $theme->get( 'Version' ), ); } return $out; } /** * Plugin folder name, or the file name for single-file plugins. * * @param string $file Plugin basename, e.g. "woocommerce/woocommerce.php". */ public static function plugin_slug( string $file ): string { $dir = dirname( $file ); return '.' === $dir ? basename( $file, '.php' ) : $dir; } /** * Activation or deactivation of one plugin. * * @param string $kind activate|deactivate. * @param string $file Plugin basename. */ private function record_plugin( string $kind, string $file ): void { if ( '' === $file ) { return; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $plugins = get_plugins(); $data = $plugins[ $file ] ?? array(); $this->add( array( 'time' => time(), 'kind' => $kind, 'type' => 'plugin', 'slug' => self::plugin_slug( $file ), 'name' => (string) ( $data['Name'] ?? self::plugin_slug( $file ) ), 'from' => null, 'to' => isset( $data['Version'] ) ? (string) $data['Version'] : null, ) ); } /** * Append one change; drops entries older than KEEP and beyond MAX. * * @param array{time: int, kind: string, type: string, slug: string, name: string, from: string|null, to: string|null} $change Change. */ private function add( array $change ): void { $change['name'] = function_exists( 'mb_substr' ) ? mb_substr( wp_strip_all_tags( $change['name'] ), 0, 80 ) : substr( wp_strip_all_tags( $change['name'] ), 0, 80 ); $rows = $this->all(); array_unshift( $rows, $change ); $cutoff = time() - self::KEEP; $rows = array_values( array_filter( $rows, static function ( array $row ) use ( $cutoff ): bool { return $row['time'] >= $cutoff; } ) ); update_option( self::OPTION, array_slice( $rows, 0, self::MAX ), false ); } /** * Split "type:slug". * * @param string $key Snapshot key. * @return array{0: string, 1: string} */ private static function split( string $key ): array { $parts = explode( ':', $key, 2 ); return array( $parts[0], $parts[1] ?? '' ); } }