array(), 'total' => 0, 'truncated' => false, ); } $candidates = $this->candidates( $value, $variable ); $items = array(); foreach ( $candidates['posts'] as $post ) { $analysis = $this->analyze_content( $post['post_content'], $value ); if ( ! $analysis['supported'] && ! $analysis['unsupported'] ) { continue; } $can_edit = current_user_can( 'edit_post', $post['ID'] ); $items[] = array( 'id' => (int) $post['ID'], 'title' => get_the_title( $post['ID'] ) ? get_the_title( $post['ID'] ) : __( '(no title)', 'glovario' ), 'post_type' => $post['post_type'], 'status' => $post['post_status'], 'edit_link' => $can_edit ? get_edit_post_link( $post['ID'], 'raw' ) : '', 'can_edit' => $can_edit, 'occurrences' => $analysis['supported'], 'unsupported' => $analysis['unsupported'], 'snippet' => $analysis['snippet'], ); } $items = apply_filters( 'glovario_content_discovery_results', $items, $variable ); return array( 'items' => is_array( $items ) ? $items : array(), 'total' => count( $items ), 'truncated' => $candidates['truncated'], ); } /** * Replace a variable's exact text with its token in content items. * * @param array $variable Variable. * @param int[] $post_ids Content items to convert. * @param array $selection Optional per item: post ID => array( 'matches' => int[], 'hash' => string ), * limiting the conversion to the numbered matches of matches(); the hash is * content_hash() at preview time, so a changed item is not converted blindly. * @return array Report. */ public function convert( $variable, $post_ids, $selection = array() ) { $value = (string) ( $variable['value'] ?? '' ); $key = sanitize_key( $variable['variable_key'] ?? '' ); $token = '{{glvr:' . $key . '}}'; $report = array( 'succeeded' => 0, 'failed' => 0, 'replacements' => 0, 'items' => array(), ); foreach ( array_values( array_unique( array_map( 'absint', (array) $post_ids ) ) ) as $post_id ) { if ( ! $post_id ) { continue; } $post = get_post( $post_id ); if ( ! $post || ! $this->is_supported_post_type( $post->post_type, $variable ) ) { $this->failure( $report, $post_id, __( 'The content item no longer exists or its post type is not public.', 'glovario' ) ); continue; } if ( ! current_user_can( 'edit_post', $post_id ) ) { $this->failure( $report, $post_id, __( 'You do not have permission to edit this content item.', 'glovario' ), $post->post_title ); continue; } if ( '' === $value || '' === $key ) { $this->failure( $report, $post_id, __( 'The variable has no exact value that can be converted.', 'glovario' ), $post->post_title ); continue; } $only = null; if ( isset( $selection[ $post_id ] ) ) { if ( ! hash_equals( self::content_hash( $post->post_content ), (string) ( $selection[ $post_id ]['hash'] ?? '' ) ) ) { $this->failure( $report, $post_id, __( 'This content item changed after the preview. Review it again.', 'glovario' ), $post->post_title ); continue; } $only = array_map( 'absint', (array) ( $selection[ $post_id ]['matches'] ?? array() ) ); } $converted = $this->convert_content( $post->post_content, $value, $token, $only ); if ( ! $converted['replacements'] ) { $this->failure( $report, $post_id, __( 'No supported exact matches remain in this content item.', 'glovario' ), $post->post_title ); continue; } if ( self::would_lose_markup( $converted['content'] ) ) { $this->failure( $report, $post_id, __( 'This content item contains markup that your account cannot save (such as an embedded frame or a script), so it was left unchanged. A user who can save unfiltered HTML can convert it.', 'glovario' ), $post->post_title ); continue; } // wp_update_post() unslashes what it is given: slash it, or every backslash is lost. $result = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $converted['content'], ) ), true ); if ( is_wp_error( $result ) ) { $this->failure( $report, $post_id, $result->get_error_message(), $post->post_title ); continue; } ++$report['succeeded']; $report['replacements'] += $converted['replacements']; $report['items'][] = array( 'id' => $post_id, 'title' => $post->post_title ? $post->post_title : __( '(no title)', 'glovario' ), 'success' => true, 'replacements' => $converted['replacements'], ); } do_action( 'glovario_content_conversion_completed', $report, $variable ); return $report; } public function analyze_content( $content, $value ) { $analysis = array( 'supported' => 0, 'unsupported' => 0, 'snippet' => '', ); $this->analyze_blocks( parse_blocks( $content ), (string) $value, $analysis ); return $analysis; } /** * Replace exact matches with the token. * * @param string $content Post content. * @param string $value Text to find. * @param string $token Replacement. * @param int[]|null $only Match numbers (as numbered by matches()) to replace; null for all. * @return array Content and replacement count. */ /** * Whether saving this text would lose markup for the current user. * WordPress filters a whole post through kses when the user cannot save * unfiltered HTML (for example site administrators on multisite), so a * rewrite of one phrase could strip an embedded frame or a script that its * author was allowed to add. Only removed elements and attributes count; * kses's harmless normalizations (entities, quotes) do not. * * @param string $html New field value. * @param string $field post_content, post_title, or post_excerpt. * @return bool */ public static function would_lose_markup( $html, $field = 'post_content' ) { if ( current_user_can( 'unfiltered_html' ) || false === strpos( (string) $html, '<' ) ) { return false; } $filtered = 'post_content' === $field ? wp_kses_post( $html ) : wp_kses_data( $html ); return self::markup_signature( $html ) !== self::markup_signature( $filtered ); } private static function markup_signature( $html ) { preg_match_all( '#<(/?)([a-zA-Z][\w:-]*)([^>]*)>#', (string) $html, $tags, PREG_SET_ORDER ); $signature = array(); foreach ( $tags as $tag ) { $signature[] = $tag[1] . strtolower( $tag[2] ) . ':' . count( wp_kses_hair( $tag[3], wp_allowed_protocols() ) ); } return $signature; } public function convert_content( $content, $value, $token, $only = null ) { $count = 0; $ordinal = 0; $only = null === $only ? null : array_flip( array_map( 'intval', (array) $only ) ); $blocks = $this->convert_blocks( parse_blocks( $content ), (string) $value, (string) $token, $count, $ordinal, $only ); return array( 'content' => serialize_blocks( $blocks ), 'replacements' => $count, ); } /** * Every supported exact match in a content item, numbered in the order * convert_content() visits them, with the text around each one. * * @param string $content Post content. * @param string $value Text to find. * @param int $limit Maximum number of matches returned. * @return array[] Each with index, before, match, and after (plain text). */ public function matches( $content, $value, $limit = 100 ) { $found = array(); $ordinal = 0; $pattern = $this->source_pattern( (string) $value ); if ( $pattern ) { $this->collect_matches( parse_blocks( $content ), $pattern, $ordinal, $found, $limit ); } return $found; } /** * Fingerprint of a content item, used to detect changes between preview and conversion. * * @param string $content Post content. * @return string */ public static function content_hash( $content ) { return md5( (string) $content ); } private function collect_matches( $blocks, $pattern, &$ordinal, &$found, $limit ) { $supported_blocks = $this->supported_blocks(); foreach ( $blocks as $block ) { if ( in_array( $block['blockName'], $supported_blocks, true ) ) { foreach ( (array) ( $block['innerContent'] ?? array() ) as $inner ) { if ( ! is_string( $inner ) ) { continue; } foreach ( $this->html_text_parts( $inner ) as $part ) { if ( ! $part['text'] ) { continue; } foreach ( $this->shortcode_parts( $part['value'] ) as $shortcode_part ) { if ( ! $shortcode_part['text'] || ! preg_match_all( $pattern, $shortcode_part['value'], $hits, PREG_OFFSET_CAPTURE ) ) { continue; } foreach ( $hits[0] as $hit ) { if ( count( $found ) < $limit ) { $before = $this->plain( substr( $shortcode_part['value'], 0, $hit[1] ) ); $after = $this->plain( substr( $shortcode_part['value'], $hit[1] + strlen( $hit[0] ) ) ); $found[] = array( 'index' => $ordinal, 'before' => ( mb_strlen( $before ) > 60 ? '…' : '' ) . mb_substr( $before, -60 ), 'match' => $this->plain( $hit[0] ), 'after' => mb_substr( $after, 0, 60 ) . ( mb_strlen( $after ) > 60 ? '…' : '' ), ); } ++$ordinal; } } } } } if ( ! empty( $block['innerBlocks'] ) ) { $this->collect_matches( $block['innerBlocks'], $pattern, $ordinal, $found, $limit ); } } } private function plain( $html ) { return preg_replace( '/\s+/', ' ', $this->decode_text( $html ) ); } private function candidates( $value, $variable ) { global $wpdb; $post_types = $this->post_types( $variable ); if ( ! $post_types ) { return array( 'posts' => array(), 'truncated' => false, ); } $limit = min( 2000, max( 1, absint( apply_filters( 'glovario_content_discovery_limit', 500, $variable ) ) ) ); $placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); $candidate_terms = $this->candidate_terms( $value ); $match_conditions = implode( ' OR ', array_fill( 0, count( $candidate_terms ), 'post_content LIKE %s' ) ); $match_values = array_map( static function ( $variant ) use ( $wpdb ) { return '%' . $wpdb->esc_like( $variant ) . '%'; }, $candidate_terms ); $values = array_merge( array( $wpdb->posts ), $post_types, $match_values, array( $limit + 1 ) ); $sql = "SELECT ID, post_title, post_type, post_status, post_content FROM %i WHERE post_type IN ({$placeholders}) AND post_status NOT IN ('trash','auto-draft','inherit') AND ({$match_conditions}) ORDER BY ID DESC LIMIT %d"; $posts = $wpdb->get_results( $wpdb->prepare( $sql, $values ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $sql holds placeholders only (their count follows the inputs). A content search WP_Query cannot express (several LIKE terms on post_content), run when an administrator asks for it. $truncated = count( $posts ) > $limit; return array( 'posts' => array_slice( $posts, 0, $limit ), 'truncated' => $truncated, ); } private function post_types( $variable ) { $post_types = get_post_types( array( 'public' => true ), 'names' ); unset( $post_types['attachment'] ); $post_types = apply_filters( 'glovario_content_discovery_post_types', array_values( $post_types ), $variable ); return array_values( array_filter( array_map( 'sanitize_key', is_array( $post_types ) ? $post_types : array() ) ) ); } private function is_supported_post_type( $post_type, $variable ) { return in_array( sanitize_key( $post_type ), $this->post_types( $variable ), true ); } private function supported_blocks() { $blocks = apply_filters( 'glovario_content_conversion_supported_blocks', array( null, 'core/freeform', 'core/paragraph', 'core/heading', 'core/list', 'core/list-item', 'core/quote', 'core/pullquote', 'core/table', 'core/verse' ) ); return is_array( $blocks ) ? $blocks : array(); } private function analyze_blocks( $blocks, $value, &$analysis ) { $supported_blocks = $this->supported_blocks(); foreach ( $blocks as $block ) { $supported = in_array( $block['blockName'], $supported_blocks, true ); foreach ( (array) ( $block['innerContent'] ?? array() ) as $inner ) { if ( ! is_string( $inner ) ) { continue; } $matches = $supported ? $this->text_occurrences( $inner, $value ) : $this->unsupported_occurrences( $inner, $value ); $analysis[ $supported ? 'supported' : 'unsupported' ] += $matches['count']; if ( '' === $analysis['snippet'] && $matches['snippet'] ) { $analysis['snippet'] = $matches['snippet']; } } if ( ! empty( $block['innerBlocks'] ) ) { $this->analyze_blocks( $block['innerBlocks'], $value, $analysis ); } } } private function convert_blocks( $blocks, $value, $token, &$count, &$ordinal, $only ) { $supported_blocks = $this->supported_blocks(); foreach ( $blocks as &$block ) { if ( in_array( $block['blockName'], $supported_blocks, true ) ) { foreach ( (array) ( $block['innerContent'] ?? array() ) as $index => $inner ) { if ( is_string( $inner ) ) { $block['innerContent'][ $index ] = $this->replace_html_text( $inner, $value, $token, $count, $ordinal, $only ); } } // The block parser defines innerHTML as the string parts of innerContent. $block['innerHTML'] = implode( '', array_filter( (array) $block['innerContent'], 'is_string' ) ); } if ( ! empty( $block['innerBlocks'] ) ) { $block['innerBlocks'] = $this->convert_blocks( $block['innerBlocks'], $value, $token, $count, $ordinal, $only ); } } unset( $block ); return $blocks; } private function text_occurrences( $html, $value ) { $count = 0; $snippet = ''; foreach ( $this->html_text_parts( $html ) as $part ) { if ( ! $part['text'] ) { continue; } foreach ( $this->shortcode_parts( $part['value'] ) as $shortcode_part ) { if ( ! $shortcode_part['text'] ) { continue; } $found = $this->source_occurrences( $shortcode_part['value'], $value ); $count += $found; if ( $found && '' === $snippet ) { $snippet = $this->snippet( $this->decode_text( $shortcode_part['value'] ), $value ); } } } return compact( 'count', 'snippet' ); } private function unsupported_occurrences( $html, $value ) { $source = wp_strip_all_tags( (string) $html ); $count = $this->source_occurrences( $source, $value ); $text = $this->decode_text( $source ); return array( 'count' => $count, 'snippet' => $count ? $this->snippet( $text, $value ) : '', ); } private function replace_html_text( $html, $value, $token, &$count, &$ordinal, $only ) { $parts = $this->html_text_parts( $html ); foreach ( $parts as &$part ) { if ( ! $part['text'] ) { continue; } $shortcode_parts = $this->shortcode_parts( $part['value'] ); foreach ( $shortcode_parts as &$shortcode_part ) { if ( ! $shortcode_part['text'] ) { continue; } $pattern = $this->source_pattern( $value ); if ( $pattern ) { $shortcode_part['value'] = preg_replace_callback( $pattern, static function ( $hit ) use ( $token, &$count, &$ordinal, $only ) { $number = $ordinal++; if ( null !== $only && ! isset( $only[ $number ] ) ) { return $hit[0]; } ++$count; return $token; }, $shortcode_part['value'] ); } } unset( $shortcode_part ); $part['value'] = implode( '', wp_list_pluck( $shortcode_parts, 'value' ) ); } unset( $part ); return implode( '', wp_list_pluck( $parts, 'value' ) ); } private function source_occurrences( $source, $value ) { $pattern = $this->source_pattern( $value ); return $pattern ? (int) preg_match_all( $pattern, (string) $source ) : 0; } private function source_pattern( $value ) { $characters = preg_split( '//u', (string) $value, -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $characters ) || ! $characters ) { return ''; } $parts = array(); $charset = get_bloginfo( 'charset' ); $charset = $charset ? $charset : 'UTF-8'; foreach ( $characters as $character ) { $alternatives = array(); foreach ( array( esc_html( $character ), htmlentities( $character, ENT_QUOTES | ENT_HTML5, $charset ) ) as $encoded ) { if ( $encoded !== $character ) { $alternatives[] = preg_quote( $encoded, '~' ); } } $codepoint = $this->unicode_codepoint( $character ); if ( $this->character_may_be_encoded( $character, $codepoint ) ) { $alternatives[] = '�*' . $codepoint . ';'; $alternatives[] = '(?i:�*' . dechex( $codepoint ) . ';)'; } $alternatives[] = preg_quote( $character, '~' ); $parts[] = '(?:' . implode( '|', array_unique( $alternatives ) ) . ')'; } return '~' . implode( '', $parts ) . '~u'; } private function candidate_terms( $value ) { $terms = $this->source_variants( $value ); $characters = preg_split( '//u', (string) $value, -1, PREG_SPLIT_NO_EMPTY ); $plain = ''; $longest = ''; foreach ( is_array( $characters ) ? $characters : array() as $character ) { $codepoint = $this->unicode_codepoint( $character ); if ( ! $this->character_may_be_encoded( $character, $codepoint ) ) { $plain .= $character; if ( strlen( trim( $plain ) ) > strlen( $longest ) ) { $longest = trim( $plain ); } } else { $plain = ''; } } if ( 1 < strlen( $longest ) ) { $terms[] = $longest; } return array_values( array_unique( array_filter( $terms, 'strlen' ) ) ); } private function source_variants( $value ) { $value = (string) $value; if ( '' === $value ) { return array(); } $charset = get_bloginfo( 'charset' ); $charset = $charset ? $charset : 'UTF-8'; $decimal = ''; $hex = ''; foreach ( (array) preg_split( '//u', $value, -1, PREG_SPLIT_NO_EMPTY ) as $character ) { $codepoint = $this->unicode_codepoint( $character ); $decimal .= $this->character_may_be_encoded( $character, $codepoint ) ? '&#' . $codepoint . ';' : $character; $hex .= $this->character_may_be_encoded( $character, $codepoint ) ? '&#x' . dechex( $codepoint ) . ';' : $character; } $variants = array( $value, esc_html( $value ), htmlentities( $value, ENT_QUOTES | ENT_HTML5, $charset ), $decimal, $hex, strtr( $value, array( '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''', ) ), strtr( $value, array( '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''', ) ), strtr( $value, array( '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''', ) ), ); return array_values( array_unique( array_filter( $variants, 'strlen' ) ) ); } private function character_may_be_encoded( $character, $codepoint ) { return 127 < $codepoint || in_array( $character, array( '&', '<', '>', '"', "'" ), true ); } private function unicode_codepoint( $character ) { if ( function_exists( 'mb_ord' ) ) { return (int) mb_ord( $character, 'UTF-8' ); } $bytes = array_values( unpack( 'C*', $character ) ); if ( 1 === count( $bytes ) ) { return $bytes[0]; } if ( 2 === count( $bytes ) ) { return ( ( $bytes[0] & 0x1F ) << 6 ) | ( $bytes[1] & 0x3F ); } if ( 3 === count( $bytes ) ) { return ( ( $bytes[0] & 0x0F ) << 12 ) | ( ( $bytes[1] & 0x3F ) << 6 ) | ( $bytes[2] & 0x3F ); } return ( ( $bytes[0] & 0x07 ) << 18 ) | ( ( $bytes[1] & 0x3F ) << 12 ) | ( ( $bytes[2] & 0x3F ) << 6 ) | ( $bytes[3] & 0x3F ); } private function decode_text( $text ) { $charset = get_bloginfo( 'charset' ); return html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, $charset ? $charset : 'UTF-8' ); } private function html_text_parts( $html ) { $parts = preg_split( '~(<(?:script|style|pre|code)\b[^>]*>.*?||<[^>]+>)~is', (string) $html, -1, PREG_SPLIT_DELIM_CAPTURE ); $result = array(); foreach ( $parts as $index => $part ) { $result[] = array( 'text' => 0 === $index % 2, 'value' => $part, ); } return $result; } private function shortcode_parts( $text ) { if ( false === strpos( $text, '[' ) || ! get_shortcode_tags_in_content( $text ) ) { return array( array( 'text' => true, 'value' => $text, ), ); } $pattern = '/' . get_shortcode_regex() . '/s'; $result = array(); $shortcodes = preg_match_all( $pattern, $text, $matches, PREG_OFFSET_CAPTURE ); if ( ! $shortcodes ) { return array( array( 'text' => true, 'value' => $text, ), ); } $offset = 0; foreach ( $matches[0] as $match ) { if ( $match[1] > $offset ) { $result[] = array( 'text' => true, 'value' => substr( $text, $offset, $match[1] - $offset ), ); } $result[] = array( 'text' => false, 'value' => $match[0], ); $offset = $match[1] + strlen( $match[0] ); } if ( $offset < strlen( $text ) ) { $result[] = array( 'text' => true, 'value' => substr( $text, $offset ), ); } return $result; } private function snippet( $text, $value ) { $text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $text ) ) ); $position = strpos( $text, $value ); if ( false === $position ) { return wp_html_excerpt( $text, 180, '…' ); } $start = max( 0, $position - 70 ); $length = strlen( $value ) + 140; $prefix = $start ? '…' : ''; $suffix = $start + $length < strlen( $text ) ? '…' : ''; return $prefix . substr( $text, $start, $length ) . $suffix; } private function failure( &$report, $post_id, $message, $title = '' ) { ++$report['failed']; $report['items'][] = array( 'id' => (int) $post_id, 'title' => $title ? $title : __( '(unavailable)', 'glovario' ), 'success' => false, 'message' => $message, ); } }