'Elementor', '_breakdance_data' => 'Breakdance', '_oxygen_data' => 'Oxygen', // Oxygen 4 keeps a page as JSON (what it shows) and as shortcodes (older copies); both are changed. '_ct_builder_json' => 'Oxygen (classic)', '_ct_builder_shortcodes' => 'Oxygen (classic, signed)', 'ct_builder_json' => 'Oxygen (classic)', 'ct_builder_shortcodes' => 'Oxygen (classic, signed)', '_bricks_page_content_2' => 'Bricks', '_bricks_page_header_2' => 'Bricks', '_bricks_page_footer_2' => 'Bricks', // Beaver Builder: the published layout and the editor's draft, kept in step. '_fl_builder_data' => 'Beaver Builder', '_fl_builder_draft' => 'Beaver Builder', )); } /** * The builder fields to read and change on one post. Oxygen 4 shows a page * from its JSON; the shortcode copy beside it is signed, and changing it would * break the signature, so it is left alone wherever the JSON is there. */ function drobber_post_keys($post_id) { $keys = drobber_builder_keys(); foreach (array('_ct_builder_json' => '_ct_builder_shortcodes', 'ct_builder_json' => 'ct_builder_shortcodes') as $json => $shortcodes) { $value = get_post_meta($post_id, $json, true); if ($value !== '' && $value !== null) { unset($keys[$shortcodes], $keys['_ct_builder_shortcodes'], $keys['ct_builder_shortcodes']); } } return $keys; } /** Straight quotes for curly ones: WordPress shows ' as ’, so the page may say ’ where the post says '. */ function drobber_straight($text) { return str_replace(array('’', '‘', '“', '”'), array("'", "'", '"', '"'), $text); } /** & as & (leaving entities that are already there), for text stored HTML-escaped. */ function drobber_amp($text) { return preg_replace('/&(?!#?\w+;)/', '&', $text); } /** * Text as WordPress writes it inside a block's settings (), * which is how Divi 5 and other block builders keep their text: JSON, with * " < > & and -- written as " < > & --. */ function drobber_block_form($text) { $json = substr(wp_json_encode($text, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 1, -1); return str_replace(array('--', '<', '>', '&', '\\"'), array('\\u002d\\u002d', '\\u003c', '\\u003e', '\\u0026', '\\u0022'), $json); } /** * The ways $find may be stored, each with $replace written the same way: * as typed, HTML-escaped, with straight quotes. Inside a block's settings, * only the block forms, so what goes in there is always valid JSON. */ function drobber_forms($find, $replace, $in_block = false) { $straight = drobber_straight($find); if ($in_block) { $pairs = array( array(drobber_block_form($find), drobber_block_form($replace)), array(drobber_block_form($straight), drobber_block_form($replace)), array(drobber_block_form(htmlspecialchars($find, ENT_NOQUOTES, 'UTF-8')), drobber_block_form(drobber_amp($replace))), ); } else { $pairs = array( array($find, $replace), array(htmlspecialchars($find, ENT_NOQUOTES, 'UTF-8'), drobber_amp($replace)), array(wp_specialchars_decode($find, ENT_QUOTES), $replace), array($straight, $replace), array(htmlspecialchars($straight, ENT_NOQUOTES, 'UTF-8'), drobber_amp($replace)), ); } $seen = array(); $out = array(); foreach ($pairs as $pair) { if ($pair[0] === '' || isset($seen[$pair[0]])) { continue; } $seen[$pair[0]] = true; $out[] = $pair; } return $out; } /** Every stored form of some text, for searching the database. */ function drobber_variants($text) { $list = array(); foreach (array_merge(drobber_forms($text, ''), drobber_forms($text, '', true)) as $pair) { $list[] = $pair[0]; } return array_values(array_unique($list)); } /** The first stored form of $find that's in $text, replaced (or only counted, with $dry). */ function drobber_swap($text, $find, $replace, &$count, $dry, $in_block) { foreach (drobber_forms($find, $replace, $in_block) as $pair) { $n = substr_count($text, $pair[0]); if (!$n) { continue; } $count += $n; return $dry ? $text : str_replace($pair[0], $pair[1], $text); } return $text; } /** * Replaces $find with $replace in a value: a string, an array, an object, or * a string holding JSON (decoded, changed and encoded again, only if it * changed). Block content is taken a piece at a time: the blocks' settings in * their own escaped form, the HTML between them as it is. With $dry, only counts. */ function drobber_walk($value, $find, $replace, &$count, $dry = false) { // Beaver Builder keeps its layout as objects. if (is_object($value)) { foreach (get_object_vars($value) as $k => $v) { $value->$k = drobber_walk($v, $find, $replace, $count, $dry); } return $value; } if (is_array($value)) { foreach ($value as $k => $v) { $value[$k] = drobber_walk($v, $find, $replace, $count, $dry); } return $value; } if (!is_string($value) || $value === '') { return $value; } $start = ltrim($value); if ($start !== '' && ($start[0] === '{' || $start[0] === '[')) { $decoded = json_decode($value, true); if (is_array($decoded)) { $found = 0; $changed = drobber_walk($decoded, $find, $replace, $found, $dry); if (!$found) { return $value; } $count += $found; return $dry ? $value : wp_json_encode($changed); } } if (strpos($value, ')/s', $value, -1, PREG_SPLIT_DELIM_CAPTURE); foreach ($parts as $i => $part) { $parts[$i] = drobber_swap($part, $find, $replace, $count, $dry, $i % 2 === 1); } return $dry ? $value : implode('', $parts); } return drobber_swap($value, $find, $replace, $count, $dry, false); } /** * Ready to save: WordPress strips one level of backslashes from what it saves, * inside objects too, and wp_slash() doesn't reach into objects, so this does. */ function drobber_slash($value) { return map_deep($value, function ($v) { return is_string($v) ? addslashes($v) : $v; }); } /** A copy that changing can't touch the original through (objects are shared otherwise). */ function drobber_copy($value) { if (is_object($value)) { $copy = clone $value; foreach (get_object_vars($copy) as $name => $inner) { $copy->$name = drobber_copy($inner); } return $copy; } return is_array($value) ? array_map('drobber_copy', $value) : $value; } /** Where some text appears on one post: field => count. */ function drobber_matches($post, $text) { $where = array(); foreach (array('post_title' => 'title', 'post_content' => 'content', 'post_excerpt' => 'excerpt') as $field => $label) { $n = 0; drobber_walk($post->$field, $text, '', $n, true); if ($n) { $where[$label] = $n; } } foreach (drobber_post_keys($post->ID) as $key => $builder) { $raw = get_post_meta($post->ID, $key, true); if ($raw === '' || $raw === null) { continue; } $n = 0; drobber_walk(drobber_copy($raw), $text, '', $n, true); if ($n) { $where[$builder] = (isset($where[$builder]) ? $where[$builder] : 0) + $n; } } return $where; } function drobber_find_text($input) { global $wpdb; $text = isset($input['text']) ? (string) $input['text'] : ''; if (trim($text) === '') { return new WP_Error('drobber_no_text', 'Say what text to look for.'); } $likes = array(); foreach (drobber_variants($text) as $v) { $likes[] = '%' . $wpdb->esc_like($v) . '%'; $json = substr(wp_json_encode($v), 1, -1); $likes[] = '%' . $wpdb->esc_like($json) . '%'; $likes[] = '%' . $wpdb->esc_like(substr(wp_json_encode($json), 1, -1)) . '%'; } $likes = array_values(array_unique($likes)); $posts_or = implode(' OR ', array_fill(0, count($likes), '(post_title LIKE %s OR post_content LIKE %s OR post_excerpt LIKE %s)')); $meta_or = implode(' OR ', array_fill(0, count($likes), 'meta_value LIKE %s')); $keys = array_keys(drobber_builder_keys()); $key_in = implode(',', array_fill(0, count($keys), '%s')); $args = array(); foreach ($likes as $l) { array_push($args, $l, $l, $l); } /* * A direct, uncached search on purpose: it has to find an exact phrase in post content * and in page builders' own meta fields (Elementor, Oxygen, Bricks and others), which * WP_Query can't search, and it must see the text as it is right now, not a cached copy. * Every value is a placeholder bound by $wpdb->prepare(); $posts_or, $meta_or and * $key_in are only lists of those placeholders, one per value, built just above. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $ids = $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare "SELECT ID FROM {$wpdb->posts} WHERE post_type NOT IN ('revision','nav_menu_item','customize_changeset') AND post_status IN ('publish','draft','pending','private','future') AND ($posts_or) LIMIT 50", $args )); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $meta_ids = $wpdb->get_col($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key IN ($key_in) AND ($meta_or) LIMIT 50", array_merge($keys, $likes) )); $found = array(); foreach (array_unique(array_merge($ids, $meta_ids)) as $id) { $post = get_post((int) $id); if (!$post || $post->post_type === 'revision' || !current_user_can('edit_post', $post->ID)) { continue; } $where = drobber_matches($post, $text); if (!$where) { continue; } $found[] = array( 'post_id' => $post->ID, 'title' => get_the_title($post), 'type' => $post->post_type, 'status' => $post->post_status, 'url' => get_permalink($post), 'found_in' => $where, ); } return array('text' => $text, 'results' => $found); } /** Plain text of a value, with builder JSON reduced to its strings. */ function drobber_strings($value, &$out) { if (is_object($value)) { $value = get_object_vars($value); } if (is_array($value)) { foreach ($value as $v) { drobber_strings($v, $out); } return; } if (!is_string($value) || trim($value) === '') { return; } $decoded = json_decode($value, true); if (is_array($decoded)) { drobber_strings($decoded, $out); return; } $plain = trim(wp_strip_all_tags($value)); // Settings, ids, colours and the like aren't text anyone reads. if (strlen($plain) > 2 && preg_match('/\s/', $plain) && !preg_match('/^[#\d.\-\s%a-z]{1,12}$/', $plain)) { $out[] = $value; } } function drobber_get_page($input) { $id = isset($input['post_id']) ? (int) $input['post_id'] : 0; if (!$id && !empty($input['url'])) { $path = wp_parse_url((string) $input['url'], PHP_URL_PATH); $id = url_to_postid(home_url($path ? $path : '/')); if (!$id && (!$path || $path === '/')) { $id = (int) get_option('page_on_front'); } } $post = $id ? get_post($id) : null; if (!$post || !current_user_can('edit_post', $post->ID)) { return new WP_Error('drobber_not_found', 'No page at that address that you can edit. Try find-text with words from the page.'); } $builder = array(); foreach (drobber_post_keys($post->ID) as $key => $name) { $raw = get_post_meta($post->ID, $key, true); if ($raw !== '' && $raw !== null) { $strings = array(); drobber_strings($raw, $strings); $builder[$name] = array_slice(array_values(array_unique($strings)), 0, 300); } } return array( 'post_id' => $post->ID, 'title' => get_the_title($post), 'type' => $post->post_type, 'status' => $post->post_status, 'url' => get_permalink($post), 'content' => $post->post_content, 'builder_text' => (object) $builder, ); } /** Changes text on one post, wherever it is, and keeps what was there so it can be undone. */ function drobber_replace_text($input) { $id = isset($input['post_id']) ? (int) $input['post_id'] : 0; $find = isset($input['find']) ? (string) $input['find'] : ''; // Words and simple formatting only (bold, italic, links and the like): when there's any // HTML, WordPress's own post filter takes out scripts, event handlers and anything else // that could run on the site. Plain words (no tags, so nothing to run) are left exactly // as written, so an & stays an & in builders that print their text escaped. $replace = isset($input['replace']) ? (string) $input['replace'] : ''; if (false !== strpos($replace, '<')) { $replace = wp_kses_post($replace); } $post = $id ? get_post($id) : null; if (!$post || !current_user_can('edit_post', $post->ID)) { return new WP_Error('drobber_not_found', 'No post with that id that you can edit.'); } if ($find === '') { return new WP_Error('drobber_no_text', 'Say what text to change.'); } $limit = isset($input['expected']) ? (int) $input['expected'] : 0; $where = drobber_matches($post, $find); $total = array_sum($where); if (!$total) { return new WP_Error('drobber_not_found_text', 'That text isn’t on this post. It may span formatting: read the page with get-page and use the exact text or HTML.'); } // Oxygen's signed shortcodes, with no JSON beside them: changing them would break the signature. if (isset($where['Oxygen (classic, signed)'])) { return new WP_Error('drobber_oxygen_signed', 'This text is in an older Oxygen page that is stored as signed shortcodes, which can’t be changed from outside Oxygen without breaking it. Ask for it to be opened and saved once in Oxygen (which stores it the newer way), then try again, or change it in Oxygen by hand.'); } if ($limit && $total !== $limit) { return new WP_Error('drobber_count', sprintf('Found %d matches, not %d. Use a longer piece of text so only the right one changes.', $total, $limit)); } $backup = array('at' => time(), 'find' => $find, 'replace' => $replace, 'fields' => array(), 'meta' => array()); $update = array('ID' => $post->ID); foreach (array('post_title', 'post_content', 'post_excerpt') as $field) { $n = 0; $new = drobber_walk($post->$field, $find, $replace, $n); if ($n) { $backup['fields'][$field] = $post->$field; $update[$field] = $new; } } if (count($update) > 1) { wp_update_post(wp_slash($update)); // Keeps a revision too. } foreach (array_keys(drobber_post_keys($post->ID)) as $key) { $raw = get_post_meta($post->ID, $key, true); if ($raw === '' || $raw === null) { continue; } $n = 0; $new = drobber_walk(drobber_copy($raw), $find, $replace, $n); if ($n) { $backup['meta'][$key] = $raw; update_post_meta($post->ID, $key, drobber_slash($new)); } } $history = get_post_meta($post->ID, '_drobber_undo', true); $history = is_array($history) ? $history : array(); array_unshift($history, $backup); update_post_meta($post->ID, '_drobber_undo', drobber_slash(array_slice($history, 0, 5))); drobber_clear_caches($post->ID); return array('post_id' => $post->ID, 'changed' => $total, 'found_in' => $where, 'url' => get_permalink($post), 'undo' => 'drobber/undo-last-change'); } function drobber_undo($input) { $id = isset($input['post_id']) ? (int) $input['post_id'] : 0; $post = $id ? get_post($id) : null; if (!$post || !current_user_can('edit_post', $post->ID)) { return new WP_Error('drobber_not_found', 'No post with that id that you can edit.'); } $history = get_post_meta($post->ID, '_drobber_undo', true); if (!is_array($history) || !$history) { return new WP_Error('drobber_nothing', 'Nothing to undo on this post.'); } $last = array_shift($history); if (!empty($last['fields'])) { wp_update_post(wp_slash(array_merge(array('ID' => $post->ID), $last['fields']))); } foreach ((array) $last['meta'] as $key => $raw) { update_post_meta($post->ID, $key, drobber_slash($raw)); } update_post_meta($post->ID, '_drobber_undo', drobber_slash($history)); drobber_clear_caches($post->ID); return array('post_id' => $post->ID, 'undone' => array('find' => $last['find'], 'replace' => $last['replace']), 'url' => get_permalink($post)); } /** * Builders cache each page's CSS and rendered parts: mark this page's stale, * and the builder makes it again on the next view. Never rebuilt here: that * can take more memory than one request has (Oxygen 6 ran out doing it). */ function drobber_clear_caches($id) { clean_post_cache($id); // Elementor: this page's CSS and its element cache. delete_post_meta($id, '_elementor_css'); delete_post_meta($id, '_elementor_element_cache'); // Breakdance and Oxygen 6: the page's CSS files and element list. foreach (array('_breakdance_', '_oxygen_') as $prefix) { delete_post_meta($id, $prefix . 'css_file_paths_cache'); delete_post_meta($id, $prefix . 'dependency_cache'); } if (class_exists('FLBuilderModel') && method_exists('FLBuilderModel', 'delete_all_asset_cache')) { \FLBuilderModel::delete_all_asset_cache($id); } do_action('drobber_text_changed', $id); } /* ---------- Registered with the Abilities API (WordPress 6.9+) ---------- */ add_action('wp_abilities_api_categories_init', function () { if (function_exists('wp_register_ability_category')) { wp_register_ability_category('drobber', array( 'label' => 'Drobber', 'description' => 'Fix client feedback from Drobber: find and change text on this site.', )); } }); add_action('wp_abilities_api_init', function () { if (!function_exists('wp_register_ability')) { return; } $can_edit = function () { return current_user_can('edit_posts'); }; $post_id = array('type' => 'integer', 'description' => 'The post or page id, from find-text or get-page.'); wp_register_ability('drobber/find-text', array( 'label' => 'Find text', 'description' => 'Finds which pages and posts contain some text, in their content or in a page builder (Elementor, Breakdance, Oxygen). Start here: use words from the client\'s comment, or the text being changed.', 'category' => 'drobber', 'input_schema' => array( 'type' => 'object', 'properties' => array('text' => array('type' => 'string', 'description' => 'The text to look for, as it appears on the page.')), 'required' => array('text'), ), 'execute_callback' => 'drobber_find_text', 'permission_callback' => $can_edit, 'meta' => array('annotations' => array('readonly' => true, 'idempotent' => true), 'mcp' => array('public' => true)), )); wp_register_ability('drobber/get-page', array( 'label' => 'Read a page', 'description' => 'Reads a page or post by its address or id: its content (HTML) and the text inside its page builder.', 'category' => 'drobber', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'url' => array('type' => 'string', 'description' => 'The page\'s address on this site.'), 'post_id' => $post_id, ), ), 'execute_callback' => 'drobber_get_page', 'permission_callback' => $can_edit, 'meta' => array('annotations' => array('readonly' => true, 'idempotent' => true), 'mcp' => array('public' => true)), )); wp_register_ability('drobber/replace-text', array( 'label' => 'Change text', 'description' => 'Changes text on one page or post, wherever it is (content, title, or page builder data), and clears the builder\'s cache. Changes go live straight away; the previous text is kept, so undo-last-change puts it back. Use text long enough to match only what the comment means, and pass expected to be sure.', 'category' => 'drobber', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => $post_id, 'find' => array('type' => 'string', 'description' => 'The exact text (or HTML) there now.'), 'replace' => array('type' => 'string', 'description' => 'What it should say instead. Simple formatting is fine in content, e.g. or a link; scripts and other code are removed.'), 'expected' => array('type' => 'integer', 'description' => 'How many matches you expect. If the count differs, nothing changes.'), ), 'required' => array('post_id', 'find', 'replace'), ), 'execute_callback' => 'drobber_replace_text', 'permission_callback' => $can_edit, 'meta' => array('annotations' => array('destructive' => false, 'idempotent' => false), 'mcp' => array('public' => true)), )); wp_register_ability('drobber/undo-last-change', array( 'label' => 'Undo the last change', 'description' => 'Puts back the text from before the last replace-text on a page or post.', 'category' => 'drobber', 'input_schema' => array( 'type' => 'object', 'properties' => array('post_id' => $post_id), 'required' => array('post_id'), ), 'execute_callback' => 'drobber_undo', 'permission_callback' => $can_edit, 'meta' => array('annotations' => array('destructive' => false), 'mcp' => array('public' => true)), )); });