plugin name, built from the installed plugins. * * @var array|null */ private $owner_prefixes = null; /** * Installed plugin slug => plugin name. * * @var array */ private $installed_slugs = array(); /** * Constructor. * * @param Classifier $classifier Classifier. */ public function __construct( Classifier $classifier ) { $this->classifier = $classifier; } /** * Returns the inventory as class => list of rows. * * Each row: kind (theme, plugin, post_type, option, table), name, count, detail. * * @return array>> */ public function build(): array { $groups = array_fill_keys( array_keys( Classifier::labels() ), array() ); foreach ( $this->code_rows() as $row ) { $groups[ $row['class'] ][] = $row; } foreach ( $this->post_type_rows() as $row ) { $groups[ $row['class'] ][] = $row; } foreach ( $this->option_rows() as $row ) { $groups[ $row['class'] ][] = $row; } foreach ( $this->table_rows() as $row ) { $groups[ $row['class'] ][] = $row; } // Unassigned rows are a to-do list: group them by owning plugin, unknown ones last. usort( $groups[ Classifier::UNASSIGNED ], function ( $a, $b ) { return array( '' === $a['detail'], $a['detail'], $a['name'] ) <=> array( '' === $b['detail'], $b['detail'], $b['name'] ); } ); return $groups; } /** * Themes and plugins. The active theme, its parent and active plugins are marked. * * @return array> */ private function code_rows(): array { $rows = array(); $stylesheet = get_stylesheet(); $template = get_template(); foreach ( wp_get_themes() as $slug => $theme ) { $state = $slug === $stylesheet ? __( 'active', 'wonderful-sync' ) : ( $slug === $template ? __( 'parent of active theme', 'wonderful-sync' ) : __( 'inactive', 'wonderful-sync' ) ); $rows[] = array( 'class' => Classifier::CODE, 'kind' => 'theme', 'name' => (string) $slug, 'count' => 1, 'detail' => sprintf( '%s %s, %s', $theme->get( 'Name' ), $theme->get( 'Version' ), $state ), ); } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( get_plugins() as $file => $data ) { $rows[] = array( 'class' => Classifier::CODE, 'kind' => 'plugin', 'name' => (string) $file, 'count' => 1, 'detail' => sprintf( '%s %s, %s', $data['Name'], $data['Version'], is_plugin_active( $file ) ? __( 'active', 'wonderful-sync' ) : __( 'inactive', 'wonderful-sync' ) ), ); } foreach ( get_mu_plugins() as $file => $data ) { $rows[] = array( 'class' => $this->classifier->mu_plugin( (string) $file ), 'kind' => 'mu_plugin', 'name' => (string) $file, 'count' => 1, 'detail' => sprintf( '%s %s', $data['Name'], $data['Version'] ), ); } return $rows; } /** * One row per post type with the number of posts. * * @return array> */ private function post_type_rows(): array { global $wpdb; $rows = array(); $counts = $wpdb->get_results( "SELECT post_type, COUNT(*) AS n FROM {$wpdb->posts} WHERE post_status <> 'auto-draft' GROUP BY post_type ORDER BY post_type" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- read-only inventory, no cache needed. foreach ( (array) $counts as $row ) { $rows[] = array( 'class' => $this->classifier->post_type( (string) $row->post_type ), 'kind' => 'post_type', 'name' => (string) $row->post_type, 'count' => (int) $row->n, 'detail' => '', ); } return $rows; } /** * One row per option. Transients are folded into a single row each. * * @return array> */ private function option_rows(): array { global $wpdb; $rows = array(); $transients = 0; $names = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} ORDER BY option_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- read-only inventory, no cache needed. foreach ( (array) $names as $name ) { $name = (string) $name; if ( 0 === strpos( $name, '_transient_' ) || 0 === strpos( $name, '_site_transient_' ) ) { ++$transients; continue; } $rows[] = array( 'class' => $this->classifier->option( $name ), 'kind' => 'option', 'name' => $name, 'count' => 1, 'detail' => $this->owner( $name ), ); } if ( $transients > 0 ) { $rows[] = array( 'class' => Classifier::CACHE, 'kind' => 'option', 'name' => '_transient_*', 'count' => $transients, 'detail' => '', ); } return $rows; } /** * One row per table that is not part of the core post/option tables already listed. * * @return array> */ private function table_rows(): array { global $wpdb; $rows = array(); $tables = $wpdb->get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- read-only inventory, no cache needed. $skip = array( 'options', 'posts', 'postmeta' ); foreach ( (array) $tables as $table ) { $table = (string) $table; if ( in_array( substr( $table, strlen( $wpdb->prefix ) ), $skip, true ) ) { continue; } $rows[] = array( 'class' => $this->classifier->table( $table, $wpdb->prefix ), 'kind' => 'table', 'name' => $table, 'count' => 1, 'detail' => $this->owner( substr( $table, strlen( $wpdb->prefix ) ) ), ); } return $rows; } /** * Guesses which installed plugin an option or table belongs to. * * Most plugins prefix their options with their slug, an underscore form of it * or its first word (updraft_, cmplz_ for Complianz needs a rule, rcb- for Real * Cookie Banner does not). The guess only labels rows; it never changes the class. * * @param string $name Option name or table name without the database prefix. * @return string Plugin name, or '' when no plugin matches. */ public function owner( string $name ): string { if ( null === $this->owner_prefixes ) { $this->owner_prefixes = $this->build_owner_prefixes(); } $best = ''; foreach ( array_keys( $this->owner_prefixes ) as $prefix ) { if ( 0 === stripos( $name, $prefix ) && strlen( $prefix ) > strlen( $best ) ) { $best = (string) $prefix; } } if ( '' !== $best ) { return $this->owner_prefixes[ $best ]; } $known = $this->classifier->known_plugin( $name ); if ( ! $known ) { return ''; } if ( isset( $this->installed_slugs[ $known[2] ] ) ) { return $this->installed_slugs[ $known[2] ]; } /* translators: %s: plugin name. */ return sprintf( __( '%s (not installed)', 'wonderful-sync' ), $known[0] ); } /** * Derives option prefixes from every installed plugin. * * @return array Prefix => plugin name. */ private function build_owner_prefixes(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $prefixes = array(); foreach ( get_plugins() as $file => $data ) { $slug = strtolower( dirname( $file ) ); $slug = '.' === $slug ? strtolower( basename( $file, '.php' ) ) : $slug; $this->installed_slugs[ $slug ] = (string) $data['Name']; $words = preg_split( '/[-_]/', $slug ); $cands = array( $slug . '_', $slug . '-', str_replace( '-', '_', $slug ) . '_' ); if ( ! empty( $data['TextDomain'] ) ) { $cands[] = str_replace( '-', '_', strtolower( $data['TextDomain'] ) ) . '_'; } // First word only when it is distinctive enough ("updraft", not "wp"). if ( isset( $words[0] ) && strlen( $words[0] ) >= 5 ) { $cands[] = $words[0] . '_'; $cands[] = $words[0] . '-'; } // Initials of multi-word slugs: real-cookie-banner => rcb-, rcb_. if ( count( $words ) >= 3 ) { $initials = implode( '', array_map( function ( $w ) { return substr( $w, 0, 1 ); }, $words ) ); $cands[] = $initials . '-'; $cands[] = $initials . '_'; } /** * Filters the option prefixes that identify a plugin in the inventory. * * @param string[] $cands Prefixes. * @param string $file Plugin file. */ foreach ( (array) apply_filters( 'wonderful_sync_owner_prefixes', $cands, $file ) as $prefix ) { if ( strlen( (string) $prefix ) >= 3 && ! isset( $prefixes[ $prefix ] ) ) { $prefixes[ (string) $prefix ] = (string) $data['Name']; } } } return $prefixes; } }