variables = $variables; $this->rules = $rules; $this->references = $references ? $references : new NestedReferenceParser(); $this->tokens = $tokens ? $tokens : new TokenParser(); $this->builtins = $builtins ? $builtins : new BuiltinVariables(); } public function summaries() { $index = $this->build_index(); $summaries = array(); foreach ( $this->all_variables() as $variable ) { $variable['usage_count'] = count( $index[ $variable['variable_key'] ] ?? array() ); $summaries[] = $variable; } return $summaries; } public function scan( $key, $page = 1, $per_page = 50 ) { $key = sanitize_key( $key ); $page = max( 1, absint( $page ) ); $per_page = min( 100, max( 1, absint( $per_page ) ) ); $matches = $this->matches( $key ); $total = count( $matches ); return array( 'matches' => array_slice( $matches, ( $page - 1 ) * $per_page, $per_page ), 'total' => $total, 'page' => $page, 'pages' => max( 1, (int) ceil( $total / $per_page ) ), ); } public function matches( $key ) { $key = sanitize_key( $key ); return $this->build_index()[ $key ] ?? array(); } private function build_index() { $variables = $this->all_variables(); $by_id = array(); $index = array(); foreach ( $variables as $variable ) { if ( empty( $variable['builtin'] ) ) { $by_id[ (int) $variable['id'] ] = $variable; $this->index_nested_value( $index, $variable, $variable['value'], __( 'Base value', 'glovario' ), array( 'storage' => 'variable' ) ); } } /* * Stored rules are references even when their conditions are not applied. * Keeping them in the index prevents a rename or deletion from leaving * dormant references behind. */ foreach ( $this->rules->all() as $rule ) { $variable_id = (int) $rule['variable_id']; if ( ! isset( $by_id[ $variable_id ] ) ) { continue; } /* translators: %s: conditional rule name. */ $context = sprintf( __( 'Rule: %s', 'glovario' ), $rule['name'] ? $rule['name'] : __( 'Unnamed rule', 'glovario' ) ); $this->index_nested_value( $index, $by_id[ $variable_id ], $rule['alternative_value'], $context, array( 'storage' => 'rule', 'rule_id' => (int) $rule['id'], ) ); } foreach ( $this->content_candidates() as $batch ) { foreach ( $batch as $post ) { if ( 'nav_menu_item' !== $post['post_type'] ) { $this->index_post_field( $index, $post, 'post_content', true, true, true ); $this->index_post_field( $index, $post, 'post_excerpt', false, true, false ); } $this->index_post_field( $index, $post, 'post_title', true, false, false ); } } $this->index_text_widgets( $index ); $this->index_block_widgets( $index ); return $index; } private function index_post_field( &$index, $post, $field, $shortcodes, $tokens, $blocks ) { $content = (string) ( $post[ $field ] ?? '' ); $shortcode_keys = $shortcodes ? $this->shortcode_keys_in_html_text( $content ) : array(); $token_keys = $tokens ? $this->tokens->keys_in_html_text( $content ) : array(); $block_keys = $blocks ? $this->block_keys( parse_blocks( $content ) ) : array(); $field_labels = array( 'post_content' => __( 'Content', 'glovario' ), 'post_title' => __( 'Title or menu label', 'glovario' ), 'post_excerpt' => __( 'Excerpt', 'glovario' ), ); foreach ( array_unique( array_merge( $shortcode_keys, $token_keys, $block_keys ) ) as $key ) { $sources = array(); if ( in_array( $key, $shortcode_keys, true ) ) { $sources[] = 'shortcode'; } if ( in_array( $key, $block_keys, true ) ) { $sources[] = 'block'; } if ( in_array( $key, $token_keys, true ) ) { $sources[] = 'token'; } $title = get_the_title( $post['ID'] ); $index[ $key ][] = array( 'kind' => 'content', 'storage' => $field, 'id' => (int) $post['ID'], 'title' => $title ? $title : __( '(no title)', 'glovario' ), 'item_type' => $post['post_type'], 'context' => ( $field_labels[ $field ] ?? $field ) . ' — ' . $post['post_status'], 'edit_link' => get_edit_post_link( $post['ID'], 'raw' ), 'sources' => $sources, ); } } private function index_text_widgets( &$index ) { $widgets = get_option( 'widget_text', array() ); if ( ! is_array( $widgets ) ) { return; } foreach ( $widgets as $instance => $widget ) { if ( ! is_numeric( $instance ) || ! is_array( $widget ) ) { continue; } $content = (string) ( $widget['text'] ?? '' ); $shortcode_keys = $this->shortcode_keys_in_html_text( $content ); $token_keys = $this->tokens->keys_in_html_text( $content ); foreach ( array_unique( array_merge( $shortcode_keys, $token_keys ) ) as $key ) { $sources = array(); if ( in_array( $key, $shortcode_keys, true ) ) { $sources[] = 'shortcode'; } if ( in_array( $key, $token_keys, true ) ) { $sources[] = 'token'; } /* translators: %d: text widget instance number. */ $title = sprintf( __( 'Text widget #%d', 'glovario' ), $instance ); $index[ $key ][] = array( 'kind' => 'widget', 'storage' => 'widget_text', 'id' => (int) $instance, 'title' => ! empty( $widget['title'] ) ? $widget['title'] . ' — ' . $title : $title, 'item_type' => 'widget', 'context' => __( 'Text widget content', 'glovario' ), 'edit_link' => admin_url( 'widgets.php' ), 'sources' => $sources, ); } } } /** * Block widgets (the default widget editor since WordPress 5.8) store block * markup in the widget_block option. * * @param array $index Usage index. */ private function index_block_widgets( &$index ) { $widgets = get_option( 'widget_block', array() ); if ( ! is_array( $widgets ) ) { return; } foreach ( $widgets as $instance => $widget ) { if ( ! is_numeric( $instance ) || ! is_array( $widget ) ) { continue; } $content = (string) ( $widget['content'] ?? '' ); $shortcode_keys = $this->shortcode_keys_in_html_text( $content ); $token_keys = $this->tokens->keys_in_html_text( $content ); $block_keys = $this->block_keys( parse_blocks( $content ) ); foreach ( array_unique( array_merge( $shortcode_keys, $token_keys, $block_keys ) ) as $key ) { $sources = array(); foreach ( array( 'shortcode' => $shortcode_keys, 'block' => $block_keys, 'token' => $token_keys, ) as $source => $keys ) { if ( in_array( $key, $keys, true ) ) { $sources[] = $source; } } $index[ $key ][] = array( 'kind' => 'widget', 'storage' => 'widget_block', 'id' => (int) $instance, /* translators: %d: block widget instance number. */ 'title' => sprintf( __( 'Block widget #%d', 'glovario' ), $instance ), 'item_type' => 'widget', 'context' => __( 'Block widget content', 'glovario' ), 'edit_link' => admin_url( 'widgets.php' ), 'sources' => $sources, ); } } } private function shortcode_keys_in_html_text( $content ) { $keys = array(); $parts = preg_split( '~(<(?:script|style|pre|code)\b[^>]*>.*?||<[^>]+>)~is', (string) $content, -1, PREG_SPLIT_DELIM_CAPTURE ); foreach ( $parts as $index => $part ) { if ( 0 === $index % 2 ) { $keys = array_merge( $keys, $this->references->keys( $part ) ); } } return array_values( array_unique( array_filter( $keys ) ) ); } private function index_nested_value( &$index, $variable, $value, $context, $metadata = array() ) { foreach ( $this->references->keys( $value ) as $key ) { $index[ $key ][] = array_merge( array( 'kind' => 'variable', 'id' => (int) $variable['id'], 'title' => $variable['label'] . ' (' . $variable['variable_key'] . ')', 'item_type' => 'glovario_variable', 'context' => $context, 'edit_link' => add_query_arg( array( 'page' => 'glovario', 'action' => 'edit', 'id' => $variable['id'], ), admin_url( 'admin.php' ) ), 'sources' => array( 'nested' ), ), $metadata ); } } /** * Posts that may reference a variable, in batches of 200 so that sites with * thousands of long posts do not run out of memory. Every candidate is * read: deletion and key migration must find every reference. * * @return \Generator Lists of post rows. */ private function content_candidates() { global $wpdb; $like_shortcode = '%' . $wpdb->esc_like( '[glvr' ) . '%'; $like_block = '%' . $wpdb->esc_like( 'wp:glovario/site-variable' ) . '%'; $like_token = '%' . $wpdb->esc_like( '{{glvr:' ) . '%'; $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A content search WP_Query cannot express (LIKE on content, title, and excerpt), run when an administrator scans. $wpdb->prepare( "SELECT ID FROM %i WHERE post_status NOT IN ('trash','auto-draft','inherit') AND post_type <> 'revision' AND (post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s OR post_title LIKE %s OR post_excerpt LIKE %s) ORDER BY ID DESC", $wpdb->posts, $like_shortcode, $like_token, $like_block, $like_shortcode, $like_token ) ); foreach ( array_chunk( array_map( 'absint', $ids ), 200 ) as $chunk ) { $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); yield $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads the matched posts in batches of 200 during the same administrator scan. $wpdb->prepare( "SELECT ID, post_title, post_type, post_status, post_content, post_excerpt FROM %i WHERE ID IN ({$placeholders}) ORDER BY ID DESC", array_merge( array( $wpdb->posts ), $chunk ) ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a list of %d placeholders, one per ID. ARRAY_A ); } } private function all_variables() { $variables = $this->builtins->all(); $offset = 0; $count = 0; do { $batch = $this->variables->all( array( 'number' => 500, 'offset' => $offset, ) ); $variables = array_merge( $variables, $batch ); $count = count( $batch ); $offset += $count; } while ( 500 === $count ); return $variables; } private function block_keys( $blocks ) { $keys = array(); foreach ( $blocks as $block ) { if ( 'glovario/site-variable' === $block['blockName'] && ! empty( $block['attrs']['key'] ) ) { $keys[] = sanitize_key( $block['attrs']['key'] ); } if ( in_array( $block['blockName'], array( 'core/navigation', 'core/navigation-link', 'core/navigation-submenu', 'core/page-list' ), true ) ) { foreach ( (array) ( $block['attrs'] ?? array() ) as $attribute ) { if ( is_string( $attribute ) ) { $keys = array_merge( $keys, $this->references->keys( $attribute ) ); } } } if ( ! empty( $block['innerBlocks'] ) ) { $keys = array_merge( $keys, $this->block_keys( $block['innerBlocks'] ) ); } } return array_values( array_unique( array_filter( $keys ) ) ); } }