Created
March 4, 2026 05:57
-
-
Save arenagroove/feb470d2da7fc3d36d2e4e85fd08f0be to your computer and use it in GitHub Desktop.
WordPress MU plugin for DB search and replace. Preview first, select rows, then apply. Hard-locked by default: attachments, core options, media paths, GUIDs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: LR DB Search & Replace (MU) | |
| * Description: Safe serialization-aware DB search & replace. Protected by | |
| * default — attachments, core options, media paths, GUIDs are | |
| * always locked unless you explicitly unlock them. | |
| * Author: Luis Martinez / Less Rain | |
| * Version: 4.0.0 | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) { exit; } | |
| if ( is_admin() ) { | |
| add_action( 'wp_ajax_lr_dbsr_scan', [ 'LR_DB_SR', 'ajax_scan' ] ); | |
| add_action( 'wp_ajax_lr_dbsr_write', [ 'LR_DB_SR', 'ajax_write' ] ); | |
| add_action( 'admin_menu', function () { | |
| $base = basename( $_SERVER['PHP_SELF'] ?? '' ); | |
| if ( in_array( $base, [ 'post.php', 'post-new.php', 'edit.php' ], true ) ) { return; } | |
| add_management_page( 'LR DB S&R', 'LR DB S&R', 'manage_options', 'lr-db-sr', [ 'LR_DB_SR', 'page' ] ); | |
| } ); | |
| } | |
| // ───────────────────────────────────────────────────────────────────────────── | |
| class LR_DB_SR { | |
| private const NONCE = 'lr_dbsr_nonce'; | |
| private const CONFIRM = 'REPLACE'; | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // These option_name values lock the ENTIRE ROW — option_value is also | |
| // never written when the key matches. Add more here if needed. | |
| // ───────────────────────────────────────────────────────────────────────── | |
| private static function locked_options(): array { | |
| global $wpdb; | |
| $p = $wpdb->prefix; | |
| return [ | |
| // Site identity & URLs — changing breaks every page load | |
| 'siteurl', 'home', 'blogname', 'blogdescription', | |
| // User roles & capabilities — changing locks users out or grants wrong access | |
| $p . 'user_roles', $p . 'capabilities', | |
| // Active plugins — corrupting this string disables all plugins at once | |
| 'active_plugins', 'active_sitewide_plugins', | |
| // Active theme — changing this switches the theme unexpectedly | |
| 'template', 'stylesheet', 'current_theme', | |
| // Security keys & salts — changing logs out every logged-in user | |
| 'auth_key', 'secure_auth_key', 'logged_in_key', 'nonce_key', | |
| 'auth_salt', 'secure_auth_salt', 'logged_in_salt', 'nonce_salt', | |
| // Rewrite & cron — changing breaks URLs and scheduled jobs | |
| 'rewrite_rules', 'cron', | |
| // DB & uploads config | |
| 'db_version', 'upload_path', 'upload_url_path', | |
| 'uploads_use_yearmonth_folders', | |
| // Admin | |
| 'admin_email', 'recently_activated', | |
| ]; | |
| } | |
| private static function is_locked_option( string $name ): bool { | |
| if ( in_array( $name, self::locked_options(), true ) ) { return true; } | |
| // theme_mods_{theme} — stores theme customiser settings | |
| if ( str_starts_with( $name, 'theme_mods_' ) ) { return true; } | |
| // Any prefix_user_roles variant | |
| if ( str_ends_with( $name, '_user_roles' ) ) { return true; } | |
| // WordPress transients — internal cache, not content | |
| if ( str_starts_with( $name, '_transient_' ) ) { return true; } | |
| if ( str_starts_with( $name, '_site_transient_' ) ) { return true; } | |
| return false; | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Page | |
| // ───────────────────────────────────────────────────────────────────────── | |
| public static function page(): void { | |
| if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Access denied.' ); } | |
| @wp_raise_memory_limit( 'admin' ); | |
| global $wpdb; | |
| $tables = $wpdb->get_col( 'SHOW TABLES' ); | |
| sort( $tables, SORT_NATURAL | SORT_FLAG_CASE ); | |
| ?> | |
| <div class="wrap lr-wrap"> | |
| <div class="lr-hd"> | |
| <h1>LR DB Search & Replace</h1> | |
| <span class="lr-badge-danger">Always preview before applying</span> | |
| </div> | |
| <form id="lr-form" data-tables="<?php echo esc_attr( wp_json_encode( $tables ) ); ?>"> | |
| <?php wp_nonce_field( self::NONCE ); ?> | |
| <?php /* ── Step 1 ─────────────────────────────────────────────────── */ ?> | |
| <div class="lr-section"> | |
| <h2><span class="lr-n">1</span> Search & replace strings</h2> | |
| <div class="lr-row2"> | |
| <label class="lr-label"> | |
| <span>Search for</span> | |
| <span class="lr-sub"> </span> | |
| <input name="search" type="text" class="code lr-input" required | |
| placeholder="text or /regex/i…" autocomplete="off" /> | |
| </label> | |
| <label class="lr-label"> | |
| <span>Replace with</span> | |
| <span class="lr-sub">leave empty to only find — nothing will be written</span> | |
| <input name="replace" type="text" class="code lr-input" | |
| placeholder="replacement…" autocomplete="off" /> | |
| </label> | |
| </div> | |
| <div class="lr-row-inline"> | |
| <label><input type="checkbox" name="regex" /> Use regex (PCRE)</label> | |
| <label><input type="checkbox" name="case_sensitive" /> Case sensitive</label> | |
| </div> | |
| </div> | |
| <?php /* ── Step 2 ─────────────────────────────────────────────────── */ ?> | |
| <div class="lr-section"> | |
| <h2><span class="lr-n">2</span> Tables to scan</h2> | |
| <div class="lr-table-btns"> | |
| <button type="button" class="button button-small" id="btn-all">All</button> | |
| <button type="button" class="button button-small" id="btn-none">None</button> | |
| <button type="button" class="button button-small" id="btn-core">WP core only</button> | |
| <button type="button" class="button button-small" id="btn-posts">posts + postmeta</button> | |
| <button type="button" class="button button-small" id="btn-options">options only</button> | |
| </div> | |
| <select name="tables[]" id="lr-tables" size="10" multiple> | |
| <?php foreach ( $tables as $t ) : ?> | |
| <option value="<?php echo esc_attr( $t ); ?>"><?php echo esc_html( $t ); ?></option> | |
| <?php endforeach; ?> | |
| </select> | |
| <p class="lr-hint">Leave empty = scan every table.</p> | |
| </div> | |
| <?php /* ── Step 3 ─────────────────────────────────────────────────── */ ?> | |
| <div class="lr-section"> | |
| <h2><span class="lr-n">3</span> What is always protected</h2> | |
| <p class="lr-section-intro"> | |
| The following are <strong>hard-locked by default</strong>. Each one | |
| shows what breaks if it were ever written. You can unlock them below — | |
| but only do so if you are certain. | |
| </p> | |
| <div class="lr-guards"> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🔒</span> | |
| <strong>Attachment posts & their metadata</strong> | |
| <span class="lr-default-on">on by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What this covers:</em> every post where <code>post_type = attachment</code> | |
| (images, PDFs, videos in the Media Library), <em>and every postmeta row | |
| whose post belongs to an attachment</em> — that includes alt text, image titles, | |
| captions, and any ACF field attached to a media item. | |
| </p> | |
| <p class="lr-guard-break"> | |
| <em>If you write here:</em> you rename image titles and alt text inside | |
| WordPress, but the physical file on disk does not change — broken references, | |
| broken srcsets, broken attachment pages. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="skip_attachments" value="1" checked /> | |
| Keep this protection <em>(uncheck to unlock)</em></label> | |
| </div> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🔒</span> | |
| <strong>WordPress core options</strong> | |
| <span class="lr-default-on">on by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What this covers:</em> entire rows in <code>wp_options</code> where the | |
| <code>option_name</code> is site-critical. Locked keys include: | |
| <code>siteurl</code>, <code>home</code>, <code>active_plugins</code>, | |
| <code><?php echo esc_html( $wpdb->prefix ); ?>user_roles</code>, | |
| <code>auth_key</code> & all other salts, <code>template</code>, | |
| <code>stylesheet</code>, <code>theme_mods_*</code>, <code>cron</code>, | |
| <code>rewrite_rules</code>, all transients. | |
| <br><strong>Note: the <code>option_name</code> column is never searchable | |
| regardless — you cannot rename an option key.</strong> | |
| </p> | |
| <p class="lr-guard-break"> | |
| <em>If you write here:</em> corrupting <code>active_plugins</code> disables | |
| all plugins on next load. Changing a salt logs out every user. Changing | |
| <code>theme_mods_*</code> resets theme customiser settings. | |
| Changing <code>siteurl</code> breaks the entire site immediately. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="skip_core_options" value="1" checked /> | |
| Keep this protection <em>(uncheck to unlock)</em></label> | |
| </div> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🔒</span> | |
| <strong>File paths & media URLs</strong> | |
| <span class="lr-default-on">on by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What this covers:</em> any cell whose plain-text value contains | |
| <code>/uploads/</code> or ends in a media extension | |
| (<code>.jpg .jpeg .png .gif .webp .svg .pdf .mp4 .mov</code> etc.). | |
| </p> | |
| <p class="lr-guard-break"> | |
| <em>If you write here:</em> WordPress stores file references as plain strings. | |
| Replacing text in a URL changes the reference in the database but not the | |
| actual filename on disk — the image 404s immediately. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="skip_media_paths" value="1" checked /> | |
| Keep this protection <em>(uncheck to unlock)</em></label> | |
| </div> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🔒</span> | |
| <strong>Post GUIDs</strong> | |
| <span class="lr-default-on">on by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What this covers:</em> the <code>guid</code> column in <code>wp_posts</code>. | |
| </p> | |
| <p class="lr-guard-break"> | |
| <em>If you write here:</em> RSS feed readers track posts by GUID. Changing it | |
| makes every post appear as new to subscribers, flooding them with duplicate | |
| content. WordPress itself never changes GUIDs after first publish. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="skip_guid" value="1" checked /> | |
| Keep this protection <em>(uncheck to unlock)</em></label> | |
| </div> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🔒</span> | |
| <strong>BLOB / BINARY columns</strong> | |
| <span class="lr-default-on">on by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What this covers:</em> database columns with binary data types | |
| (BLOB, BINARY, VARBINARY). These store encoded data, not readable text. | |
| </p> | |
| <p class="lr-guard-break"> | |
| <em>If you write here:</em> PHP string operations on binary data produce | |
| silent, undetectable corruption — the column looks unchanged but the bytes | |
| are wrong and whatever reads them will fail. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="skip_blobs" value="1" checked /> | |
| Keep this protection <em>(uncheck to unlock)</em></label> | |
| </div> | |
| </div><!-- .lr-guards --> | |
| <div class="lr-section-sub"> | |
| <h3>Additional options</h3> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">📦</span> | |
| <strong>Replace inside serialized values (ACF fields, option arrays)</strong> | |
| <span class="lr-default-on" style="background:#f6f7f7;color:#50575e;border-color:#dcdcde">off by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What it does:</em> unserializes the value, walks every string inside | |
| the array or object recursively, replaces, then re-serializes correctly. | |
| Required for ACF link fields, repeater rows, flexible content blocks, | |
| and serialized plugin option arrays. | |
| </p> | |
| <p class="lr-guard-what"> | |
| Without this, any cell containing serialized data is protected and | |
| will not be written — even if it contains your search term. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="allow_serialized" value="1" /> | |
| Enable serialized replacement</label> | |
| </div> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🗂</span> | |
| <strong>Include ACF options page values (<code>options_*</code> keys)</strong> | |
| <span class="lr-default-on">on by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What it covers:</em> option rows whose key starts with | |
| <code>options_</code> or <code>_options_</code> — these are ACF options | |
| page fields stored in <code>wp_options</code>. They are plugin content, | |
| not WordPress core settings, and are generally safe to replace. | |
| </p> | |
| <label class="lr-unlock"><input type="checkbox" name="scan_acf_options" value="1" checked /> | |
| Include ACF options in scan</label> | |
| </div> | |
| <div class="lr-guard"> | |
| <div class="lr-guard-hd"> | |
| <span class="lr-lock">🔑</span> | |
| <strong>Exclude postmeta keys by wildcard pattern</strong> | |
| <span class="lr-default-on" style="background:#f6f7f7;color:#50575e;border-color:#dcdcde">off by default</span> | |
| </div> | |
| <p class="lr-guard-what"> | |
| <em>What it does:</em> skips postmeta rows whose <code>meta_key</code> | |
| matches any of your patterns. Use <code>*</code> for any string, | |
| <code>?</code> for a single character. | |
| Example: <code>_wc_*, _yoast_*, _edit_*</code> | |
| </p> | |
| <input name="exclude_meta_keys" type="text" class="code lr-input-inline" | |
| placeholder="_wc_*, _yoast_*, _edit_*" /> | |
| </div> | |
| </div> | |
| </div> | |
| <?php /* ── Step 4 ─────────────────────────────────────────────────── */ ?> | |
| <div class="lr-section lr-run-section"> | |
| <h2><span class="lr-n">4</span> Run</h2> | |
| <div class="lr-run-grid"> | |
| <div class="lr-run-card lr-run-preview"> | |
| <h3>👁 Preview only</h3> | |
| <p>Scans the database and shows you exactly which cells would change and which | |
| are protected. <strong>Nothing is written.</strong></p> | |
| <button type="button" id="btn-preview" class="button button-primary lr-btn"> | |
| Run preview | |
| </button> | |
| </div> | |
| <div class="lr-run-card lr-run-live"> | |
| <h3>⚠ Apply changes</h3> | |
| <p>Run a preview first. Uncheck any rows you want to skip. Then type | |
| <strong><?php echo self::CONFIRM; ?></strong> and click Apply.</p> | |
| <div class="lr-confirm-row"> | |
| <input id="lr-confirm" type="text" | |
| placeholder="Type REPLACE…" | |
| autocomplete="off" /> | |
| <button type="button" id="btn-live" class="button lr-btn lr-btn-live" disabled> | |
| Run a preview first | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </form> | |
| <div id="lr-progress-wrap" style="display:none"> | |
| <div id="lr-progress"><div id="lr-bar"></div></div> | |
| <div id="lr-status"></div> | |
| </div> | |
| <div id="lr-results"></div> | |
| </div><!-- .lr-wrap --> | |
| <?php | |
| self::assets(); | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // AJAX | |
| // ───────────────────────────────────────────────────────────────────────── | |
| public static function ajax_scan(): void { | |
| if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Permission denied.' ); } | |
| if ( ! wp_verify_nonce( (string) ( $_POST['nonce'] ?? '' ), self::NONCE ) ) { wp_send_json_error( 'Bad nonce.' ); } | |
| $table = sanitize_text_field( (string) ( $_POST['table'] ?? '' ) ); | |
| $live = ( $_POST['mode'] ?? '' ) === 'live'; | |
| if ( $live && trim( (string) ( $_POST['confirm'] ?? '' ) ) !== self::CONFIRM ) { | |
| wp_send_json_error( 'Confirmation word incorrect.' ); | |
| } | |
| $opts = self::parse_opts(); | |
| ob_start(); | |
| self::scan_table( $table, $opts, $live ); | |
| wp_send_json_success( [ 'html' => ob_get_clean() ] ); | |
| } | |
| // Writes only the rows the user kept checked after preview. | |
| // Each row payload is base64(json({t,p,k,c,v})) where v=base64(new_value). | |
| public static function ajax_write(): void { | |
| if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Permission denied.' ); } | |
| if ( ! wp_verify_nonce( (string) ( $_POST['nonce'] ?? '' ), self::NONCE ) ) { wp_send_json_error( 'Bad nonce.' ); } | |
| if ( trim( (string) ( $_POST['confirm'] ?? '' ) ) !== self::CONFIRM ) { wp_send_json_error( 'Confirmation word incorrect.' ); } | |
| global $wpdb; | |
| $all_tables = $wpdb->get_col( 'SHOW TABLES' ); | |
| $rows = json_decode( wp_unslash( (string) ( $_POST['rows'] ?? '[]' ) ), true ); | |
| if ( ! is_array( $rows ) ) { wp_send_json_error( 'Invalid payload.' ); } | |
| $written = 0; | |
| $errors = 0; | |
| foreach ( $rows as $b64 ) { | |
| $d = json_decode( base64_decode( (string) $b64 ), true ); | |
| if ( ! is_array( $d ) ) { $errors++; continue; } | |
| $table = (string) ( $d['t'] ?? '' ); | |
| $pk_col = (string) ( $d['p'] ?? '' ); | |
| $pk_val = (string) ( $d['k'] ?? '' ); | |
| $col = (string) ( $d['c'] ?? '' ); | |
| $value = base64_decode( (string) ( $d['v'] ?? '' ) ); | |
| // Validate table is a real table in this DB — never trust client input for SQL identifiers | |
| if ( ! in_array( $table, $all_tables, true ) ) { $errors++; continue; } | |
| // Validate column exists in the table | |
| $cols = $wpdb->get_col( "SHOW COLUMNS FROM `" . str_replace( '`', '``', $table ) . "`", 0 ); | |
| if ( ! in_array( $col, $cols, true ) || ! in_array( $pk_col, $cols, true ) ) { $errors++; continue; } | |
| self::write( $table, [ $col => $value ], [ $pk_col => $pk_val ] ); | |
| $written++; | |
| } | |
| wp_send_json_success( [ 'written' => $written, 'errors' => $errors ] ); | |
| } | |
| private static function parse_opts(): array { | |
| $raw = json_decode( wp_unslash( (string) ( $_POST['opts'] ?? '{}' ) ), true ); | |
| if ( ! is_array( $raw ) ) { $raw = []; } | |
| $bool = [ 'regex','case_sensitive','skip_attachments','skip_core_options', | |
| 'skip_media_paths','skip_guid','skip_blobs','allow_serialized','scan_acf_options' ]; | |
| foreach ( $bool as $k ) { $raw[$k] = ! empty( $raw[$k] ) ? 1 : 0; } | |
| $raw['search'] = (string) ( $raw['search'] ?? '' ); | |
| $raw['replace'] = (string) ( $raw['replace'] ?? '' ); | |
| $raw['exclude_meta_keys']= (string) ( $raw['exclude_meta_keys'] ?? '' ); | |
| $raw['limit'] = max( 1, (int) ( $raw['limit'] ?? 500 ) ); | |
| return $raw; | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Core scan logic for one table | |
| // ───────────────────────────────────────────────────────────────────────── | |
| private static function scan_table( string $table, array $opts, bool $live ): void { | |
| global $wpdb; | |
| $search = $opts['search']; | |
| $replace = $opts['replace']; | |
| if ( $search === '' || ! self::table_exists( $table ) ) { return; } | |
| $regex = (bool) $opts['regex']; | |
| $case = (bool) $opts['case_sensitive']; | |
| $pattern = null; | |
| if ( $regex ) { | |
| $pattern = self::normalize_regex( $search, $case ); | |
| if ( ! $pattern ) { | |
| echo '<div class="lr-box lr-box-err"><p>Invalid regex: ' . esc_html( $search ) . '</p></div>'; | |
| return; | |
| } | |
| } | |
| // ── Get the columns we are allowed to search ────────────────────── | |
| // Rules: | |
| // - Only text-like columns (char, text, json). BLOB excluded when skip_blobs=1. | |
| // - Never the guid column in wp_posts (when skip_guid=1). | |
| // - NEVER option_name in wp_options — you cannot rename option keys via S&R. | |
| // option_name is always excluded, unconditionally, regardless of any setting. | |
| // - $pk column is determined separately and always included in SELECT but | |
| // not necessarily in the searchable columns. | |
| $safe_table = str_replace( '`', '``', $table ); | |
| $cols_info = $wpdb->get_results( "SHOW COLUMNS FROM `{$safe_table}`" ); | |
| $search_cols = []; | |
| $pk_col = null; | |
| foreach ( (array) $cols_info as $c ) { | |
| $field = (string) $c->Field; | |
| $type = strtolower( (string) $c->Type ); | |
| if ( strtoupper( (string) $c->Key ) === 'PRI' ) { $pk_col = $field; } | |
| // Hard exclusions — no setting can override these | |
| if ( $table === $wpdb->options && $field === 'option_name' ) { continue; } | |
| if ( $opts['skip_guid'] && $table === $wpdb->posts && $field === 'guid' ) { continue; } | |
| $is_text = str_contains( $type, 'char' ) || str_contains( $type, 'text' ) || str_contains( $type, 'json' ); | |
| $is_blob = str_contains( $type, 'blob' ) || str_contains( $type, 'binary' ) || str_contains( $type, 'varbinary' ); | |
| if ( $is_text ) { $search_cols[] = $field; } | |
| elseif ( $is_blob && ! $opts['skip_blobs'] ) { $search_cols[] = $field; } | |
| } | |
| if ( empty( $search_cols ) ) { return; } | |
| // ── Fetch candidate rows ─────────────────────────────────────────── | |
| $rows = self::fetch_rows( $table, $search_cols, $pk_col, $search, $regex, $case, $opts ); | |
| if ( empty( $rows ) ) { return; } | |
| // ── Evaluate each row/column ─────────────────────────────────────── | |
| $safe = []; // [ entry ] — will be / were written | |
| $guarded = []; // [ entry ] — matched but protected | |
| foreach ( $rows as $row ) { | |
| $pk_val = $pk_col ? ( $row->$pk_col ?? null ) : null; | |
| // Determine row-level guard (applies to ALL columns in this row) | |
| $row_guard = self::row_guard( $table, $row, $opts ); | |
| foreach ( $search_cols as $col ) { | |
| $val = $row->$col ?? null; | |
| if ( $val === null || $val === '' ) { continue; } | |
| $result = self::do_replace( $val, $search, $replace, $regex, $case, $pattern ); | |
| if ( $result['count'] === 0 ) { continue; } | |
| // Cell-level guard (serialized, media path) | |
| $cell_guard = $row_guard ?? self::cell_guard( $col, $val, $opts ); | |
| $entry = [ | |
| 'pk' => (string) $pk_val, | |
| 'col' => $col, | |
| 'preview' => $result['preview'], | |
| 'count' => $result['count'], | |
| 'new_value' => $result['value'], | |
| 'changed' => $result['changed'], | |
| 'links' => self::links( $table, $row ), | |
| // For wp_options: always show the option_name as context | |
| 'option_name' => ( $table === $wpdb->options && isset( $row->option_name ) ) | |
| ? (string) $row->option_name : null, | |
| ]; | |
| if ( $cell_guard ) { | |
| $entry['guard'] = $cell_guard; | |
| $guarded[] = $entry; | |
| } else { | |
| $safe[] = $entry; | |
| if ( $live && $result['changed'] && $pk_col ) { | |
| self::write( $table, [ $col => $result['value'] ], [ $pk_col => $pk_val ] ); | |
| } | |
| } | |
| } | |
| } | |
| if ( empty( $safe ) && empty( $guarded ) ) { return; } | |
| echo '<div class="lr-box">'; | |
| printf( '<h3 class="lr-box-title"><code>%s</code></h3>', esc_html( $table ) ); | |
| if ( ! empty( $safe ) ) { | |
| $label = $live ? '✓ Written to database' : '✎ Will be replaced'; | |
| $cls = $live ? 'lr-panel-written' : 'lr-panel-safe'; | |
| self::results_table( $safe, $label, $cls, $table, false, $live ? null : $pk_col ); | |
| } | |
| if ( ! empty( $guarded ) ) { | |
| self::results_table( $guarded, '🔒 Matched — but protected, will not be written', 'lr-panel-guard', $table, true ); | |
| } | |
| echo '</div>'; | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Guard logic | |
| // ───────────────────────────────────────────────────────────────────────── | |
| /** | |
| * Row-level guard: if the whole row is protected regardless of column. | |
| * Returns a reason string, or null if the row is OK to write. | |
| */ | |
| private static function row_guard( string $table, $row, array $opts ): ?string { | |
| global $wpdb; | |
| // Attachment posts | |
| if ( $opts['skip_attachments'] && $table === $wpdb->posts | |
| && isset( $row->post_type ) && $row->post_type === 'attachment' ) { | |
| return 'attachment post — this is a Media Library item (image, PDF, video)'; | |
| } | |
| // Core options — lock the ENTIRE ROW when option_name is site-critical | |
| if ( $opts['skip_core_options'] && $table === $wpdb->options && isset( $row->option_name ) ) { | |
| if ( self::is_locked_option( (string) $row->option_name ) ) { | |
| return 'core WordPress option "' . $row->option_name . '" — site-critical, do not change'; | |
| } | |
| } | |
| // ACF options opt-out | |
| if ( $table === $wpdb->options && isset( $row->option_name ) && ! $opts['scan_acf_options'] ) { | |
| $name = (string) $row->option_name; | |
| if ( str_starts_with( $name, 'options_' ) || str_starts_with( $name, '_options_' ) ) { | |
| return 'ACF option (scanning disabled by your settings)'; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Cell-level guard: checks the value itself (serialized, media path). | |
| * Only called when row_guard() returned null. | |
| */ | |
| private static function cell_guard( string $col, $val, array $opts ): ?string { | |
| if ( ! is_string( $val ) ) { return null; } | |
| $is_ser = is_serialized( $val ); | |
| // Serialized without opt-in | |
| if ( $is_ser && ! $opts['allow_serialized'] ) { | |
| return 'serialized value — turn on "Replace inside serialized values" to allow'; | |
| } | |
| // Media path — but only if the value is a plain string, not a serialized blob | |
| // (serialized cells may contain URLs inside them; the walk handles that safely) | |
| if ( $opts['skip_media_paths'] && ! $is_ser && self::is_media_path( $val ) ) { | |
| return 'looks like a file path or media URL — changing this breaks image display'; | |
| } | |
| return null; | |
| } | |
| private static function is_media_path( string $s ): bool { | |
| if ( stripos( $s, '/uploads/' ) !== false ) { return true; } | |
| if ( preg_match( '~\.(?:jpe?g|png|gif|webp|svg|pdf|zip|mp4|mov|webm|mp3|wav|ogg)(?:$|[?#\s])~i', $s ) ) { return true; } | |
| return false; | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // DB | |
| // ───────────────────────────────────────────────────────────────────────── | |
| private static function table_exists( string $table ): bool { | |
| global $wpdb; | |
| return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); | |
| } | |
| private static function fetch_rows( | |
| string $table, array $search_cols, ?string $pk, | |
| string $search, bool $regex, bool $case, array $opts | |
| ): array { | |
| global $wpdb; | |
| $safe_table = str_replace( '`', '``', $table ); | |
| // Build the WHERE clauses — one per searchable column | |
| $wheres = []; | |
| $params = []; | |
| foreach ( $search_cols as $col ) { | |
| $esc = '`' . str_replace( '`', '``', $col ) . '`'; | |
| if ( $regex ) { | |
| $wheres[] = $case ? "{$esc} COLLATE utf8mb4_bin REGEXP %s" : "{$esc} REGEXP %s"; | |
| $params[] = self::mysql_bare_regex( $search ); | |
| } else { | |
| $wheres[] = $case ? "BINARY {$esc} LIKE %s" : "{$esc} LIKE %s"; | |
| $params[] = '%' . $wpdb->esc_like( $search ) . '%'; | |
| } | |
| } | |
| $extra = ''; | |
| // Exclude attachment posts at SQL level (wp_posts) | |
| if ( $opts['skip_attachments'] && $table === $wpdb->posts ) { | |
| $extra .= ' AND `post_type` <> %s'; | |
| $params[] = 'attachment'; | |
| } | |
| // Exclude ALL postmeta belonging to attachment posts — this is the key fix. | |
| // Without the subquery, alt text / image title rows slip through. | |
| if ( $table === $wpdb->postmeta ) { | |
| $extra .= ' AND `meta_key` NOT IN (%s, %s)'; | |
| $params[] = '_wp_attached_file'; | |
| $params[] = '_wp_attachment_metadata'; | |
| if ( $opts['skip_attachments'] ) { | |
| $safe_posts = str_replace( '`', '``', $wpdb->posts ); | |
| $extra .= " AND `post_id` NOT IN (SELECT `ID` FROM `{$safe_posts}` WHERE `post_type` = 'attachment')"; | |
| // Note: no %s param here — the value is hardcoded 'attachment', not user input | |
| } | |
| // Wildcard meta key exclusions | |
| $excl = $opts['exclude_meta_keys'] ?? ''; | |
| foreach ( self::wildcards_to_likes( $excl ) as $like ) { | |
| $extra .= ' AND `meta_key` NOT LIKE %s'; | |
| $params[] = $like; | |
| } | |
| } | |
| // Lock core options at SQL level too — skip fetching rows we will never write | |
| if ( $table === $wpdb->options && $opts['skip_core_options'] ) { | |
| // We still fetch them so we can show them in the protected panel, | |
| // but we could skip entirely. For clarity we keep them and explain. | |
| } | |
| $order = $pk ? ' ORDER BY `' . str_replace( '`', '``', $pk ) . '` ASC' : ''; | |
| $params[] = max( 1, (int) $opts['limit'] ); | |
| $sql = "SELECT * FROM `{$safe_table}` WHERE (" . implode( ' OR ', $wheres ) . "){$extra}{$order} LIMIT %d"; | |
| $rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ) ); | |
| return is_array( $rows ) ? $rows : []; | |
| } | |
| private static function write( string $table, array $data, array $where ): void { | |
| global $wpdb; | |
| $wpdb->update( | |
| $table, $data, $where, | |
| array_fill( 0, count( $data ), '%s' ), | |
| array_map( fn( $v ) => is_int( $v ) ? '%d' : '%s', $where ) | |
| ); | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Replace logic | |
| // ───────────────────────────────────────────────────────────────────────── | |
| private static function do_replace( $value, string $search, string $replace, bool $regex, bool $case, ?string $pattern ): array { | |
| $count = 0; | |
| $preview = ''; | |
| $original = $value; | |
| $fn = function ( string $s ) use ( $search, $replace, $regex, $case, $pattern, &$count, &$preview ): string { | |
| if ( $s === '' ) { return $s; } | |
| if ( $regex ) { | |
| $pat = $pattern ?? LR_DB_SR::normalize_regex( $search, $case ); | |
| if ( ! $pat ) { return $s; } | |
| $n = 0; | |
| $new = @preg_replace( $pat, $replace, $s, -1, $n ); | |
| if ( $new === null ) { return $s; } | |
| if ( $n > 0 && $preview === '' && preg_match( $pat, $s, $m, PREG_OFFSET_CAPTURE ) ) { | |
| $preview = LR_DB_SR::preview_snippet( $s, (int) $m[0][1], strlen( $m[0][0] ) ); | |
| } | |
| $count += $n; | |
| return $new; | |
| } | |
| $n = 0; | |
| $new = $case | |
| ? str_replace( $search, $replace, $s, $n ) | |
| : preg_replace( '/' . preg_quote( $search, '/' ) . '/iu', $replace, $s, -1, $n ); | |
| if ( $n > 0 && $preview === '' ) { | |
| $pos = $case ? strpos( $s, $search ) : stripos( $s, $search ); | |
| if ( $pos !== false ) { | |
| $preview = LR_DB_SR::preview_snippet( $s, $pos, strlen( $search ) ); | |
| } | |
| } | |
| $count += (int) $n; | |
| return (string) $new; | |
| }; | |
| $changed = false; | |
| if ( is_string( $original ) && is_serialized( $original ) ) { | |
| $data = @maybe_unserialize( $original ); | |
| $walked = self::walk( $data, $fn ); | |
| $ser = serialize( $walked ); | |
| if ( $ser !== $original ) { $changed = true; $value = $ser; } | |
| } elseif ( is_string( $value ) ) { | |
| $new = $fn( $value ); | |
| if ( $new !== $value ) { $changed = true; $value = $new; } | |
| } | |
| return [ 'value' => $value, 'count' => $count, 'changed' => $changed, 'preview' => $preview ]; | |
| } | |
| private static function walk( $d, callable $fn ) { | |
| if ( is_string( $d ) ) { return $fn( $d ); } | |
| if ( is_array( $d ) ) { foreach ( $d as $k => $v ) { $d[$k] = self::walk( $v, $fn ); } return $d; } | |
| if ( is_object( $d ) ) { foreach ( get_object_vars( $d ) as $k => $v ) { $d->$k = self::walk( $v, $fn ); } return $d; } | |
| return $d; | |
| } | |
| public static function preview_snippet( string $s, int $pos, int $len, int $ctx = 60 ): string { | |
| $start = max( 0, $pos - $ctx ); | |
| $end = min( strlen( $s ), $pos + $len + $ctx ); | |
| $before = ( $start > 0 ? '…' : '' ) . substr( $s, $start, $pos - $start ); | |
| $match = substr( $s, $pos, $len ); | |
| $after = substr( $s, $pos + $len, $end - ( $pos + $len ) ) . ( $end < strlen( $s ) ? '…' : '' ); | |
| return sprintf( | |
| '<code class="lr-snip">%s<mark>%s</mark>%s</code>', | |
| esc_html( $before ), esc_html( $match ), esc_html( $after ) | |
| ); | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Output helpers | |
| // ───────────────────────────────────────────────────────────────────────── | |
| private static function results_table( array $entries, string $label, string $cls, string $table, bool $show_guard = false, ?string $pk_col = null ): void { | |
| $is_options = ( $table === $GLOBALS['wpdb']->options ); | |
| $has_checks = ( $pk_col !== null && ! $show_guard ); // only for the "will replace" panel | |
| echo "<div class='lr-panel {$cls}'>"; | |
| printf( '<h4 class="lr-panel-title">%s <span class="lr-cnt">%d</span></h4>', esc_html( $label ), count( $entries ) ); | |
| echo '<div class="lr-tbl-wrap"><table class="lr-tbl">'; | |
| echo '<thead><tr>'; | |
| if ( $has_checks ) { echo '<th class="lr-chk-col"><input type="checkbox" class="lr-chk-all" checked title="select / deselect all" /></th>'; } | |
| echo '<th>PK</th>'; | |
| if ( $is_options ) { echo '<th>option_name</th>'; } | |
| echo '<th>Column</th><th>Preview</th><th>#</th>'; | |
| if ( $show_guard ) { echo '<th>Why protected</th>'; } | |
| echo '<th>Links</th>'; | |
| echo '</tr></thead><tbody>'; | |
| foreach ( $entries as $e ) { | |
| echo '<tr>'; | |
| if ( $has_checks ) { | |
| $payload = base64_encode( wp_json_encode( [ | |
| 't' => $table, | |
| 'p' => $pk_col, | |
| 'k' => $e['pk'], | |
| 'c' => $e['col'], | |
| 'v' => base64_encode( (string) $e['new_value'] ), | |
| ] ) ); | |
| echo '<td class="lr-chk-col"><input type="checkbox" class="lr-row-chk" checked data-write="' . esc_attr( $payload ) . '" /></td>'; | |
| } | |
| printf( '<td><code>%s</code></td>', esc_html( $e['pk'] ) ); | |
| if ( $is_options ) { | |
| printf( '<td class="lr-opt-name"><code>%s</code></td>', | |
| $e['option_name'] !== null ? esc_html( $e['option_name'] ) : '—' ); | |
| } | |
| printf( '<td><code>%s</code></td>', esc_html( $e['col'] ) ); | |
| printf( '<td>%s</td>', $e['preview'] ?: '<em class="lr-muted">no preview</em>' ); | |
| printf( '<td class="lr-cnt-cell">%d</td>', (int) $e['count'] ); | |
| if ( $show_guard ) { printf( '<td class="lr-guard-reason">%s</td>', esc_html( $e['guard'] ?? '' ) ); } | |
| printf( '<td class="lr-links">%s</td>', $e['links'] ); | |
| echo '</tr>'; | |
| } | |
| echo '</tbody></table></div></div>'; | |
| } | |
| private static function links( string $table, $row ): string { | |
| global $wpdb; | |
| $l = []; | |
| if ( $table === $wpdb->posts && isset( $row->ID ) ) { $l[] = '<a href="' . esc_url( admin_url( 'post.php?post='.(int)$row->ID.'&action=edit' ) ) . '">Post #'.(int)$row->ID.'</a>'; } | |
| if ( $table === $wpdb->postmeta && isset( $row->post_id ) ) { $l[] = '<a href="' . esc_url( admin_url( 'post.php?post='.(int)$row->post_id.'&action=edit' ) ) . '">Post #'.(int)$row->post_id.'</a>'; } | |
| if ( $table === $wpdb->comments && isset( $row->comment_ID ) ) { $l[] = '<a href="' . esc_url( admin_url( 'comment.php?action=editcomment&c='.(int)$row->comment_ID ) ) . '">Comment #'.(int)$row->comment_ID.'</a>'; } | |
| if ( $table === $wpdb->users && isset( $row->ID ) ) { $l[] = '<a href="' . esc_url( admin_url( 'user-edit.php?user_id='.(int)$row->ID ) ) . '">User #'.(int)$row->ID.'</a>'; } | |
| return $l ? implode( ' · ', $l ) : '—'; | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // Utilities | |
| // ───────────────────────────────────────────────────────────────────────── | |
| public static function normalize_regex( string $raw, bool $case ): ?string { | |
| set_error_handler( fn() => true ); | |
| $ok = @preg_match( $raw, '' ); | |
| restore_error_handler(); | |
| if ( $ok !== false ) { return $raw; } | |
| $pat = '#' . $raw . '#u' . ( $case ? '' : 'i' ); | |
| set_error_handler( fn() => true ); | |
| $ok2 = @preg_match( $pat, '' ); | |
| restore_error_handler(); | |
| return $ok2 !== false ? $pat : null; | |
| } | |
| private static function mysql_bare_regex( string $raw ): string { | |
| if ( preg_match( '~^(.)(.*)\1[a-z]*$~s', $raw, $m ) ) { return $m[2]; } | |
| return $raw; | |
| } | |
| private static function wildcards_to_likes( string $list ): array { | |
| $out = []; | |
| foreach ( preg_split( '/[,\s]+/', trim( $list ) ) as $p ) { | |
| $p = trim( $p ); | |
| if ( $p !== '' ) { $out[] = strtr( $p, [ '*' => '%', '?' => '_' ] ); } | |
| } | |
| return $out; | |
| } | |
| // ───────────────────────────────────────────────────────────────────────── | |
| // CSS + JS | |
| // ───────────────────────────────────────────────────────────────────────── | |
| private static function assets(): void { | |
| $confirm = self::CONFIRM; | |
| ?> | |
| <style> | |
| /* ── Base ─────────────────────────────────────────────────────────────────── */ | |
| .lr-wrap { max-width: 900px; font-size: 14px; } | |
| .lr-wrap * { box-sizing: border-box; } | |
| /* ── Header ──────────────────────────────────────────────────────────────── */ | |
| .lr-hd { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; } | |
| .lr-hd h1 { margin: 0; font-size: 22px; font-weight: 600; } | |
| .lr-badge-danger { margin-left: auto; background: #fff8e5; border: 1px solid #f0c36d; | |
| border-radius: 4px; padding: 4px 12px; font-size: 13px; color: #7a5500; } | |
| /* ── Sections ────────────────────────────────────────────────────────────── */ | |
| .lr-section { background: #fff; border: 1px solid #dcdcde; border-radius: 6px; | |
| padding: 20px 24px; margin-bottom: 14px; } | |
| .lr-section h2 { font-size: 14px; font-weight: 600; color: #1d2327; | |
| margin: 0 0 18px; display: flex; align-items: center; gap: 9px; } | |
| .lr-section-intro { font-size: 13px; color: #50575e; margin: 0 0 18px; line-height: 1.6; | |
| padding: 10px 14px; background: #f6f7f7; | |
| border-left: 3px solid #2271b1; border-radius: 0 3px 3px 0; } | |
| .lr-n { display: inline-flex; align-items: center; justify-content: center; | |
| width: 20px; height: 20px; background: #2271b1; color: #fff; | |
| border-radius: 50%; font-size: 11px; font-weight: 700; flex-shrink: 0; } | |
| /* ── Step 1: inputs ──────────────────────────────────────────────────────── */ | |
| .lr-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 14px; | |
| align-items: start; } | |
| @media (max-width: 720px) { .lr-row2 { grid-template-columns: 1fr; } } | |
| .lr-label { display: flex; flex-direction: column; gap: 0; font-weight: 400; font-size: 14px; } | |
| .lr-label > span:first-child { font-weight: 500; margin-bottom: 4px; } | |
| .lr-sub { font-size: 12.5px; color: #646970; margin-bottom: 5px; line-height: 1.35; min-height: 18px; } | |
| .lr-input { width: 100% !important; margin-top: 0 !important; font-size: 14px !important; } | |
| .lr-input-inline { width: 100%; max-width: 380px; margin-top: 6px; font-size: 13px; } | |
| .lr-row-inline { display: flex; gap: 22px; flex-wrap: wrap; margin-top: 4px; } | |
| .lr-row-inline label { font-size: 14px; display: flex; align-items: center; | |
| gap: 6px; cursor: pointer; font-weight: 400; color: #3c434a; } | |
| .lr-row-inline input[type="checkbox"] { margin: 0; position: relative; top: 0; flex-shrink: 0; width: 15px; height: 15px; } | |
| /* ── Step 2: table select ────────────────────────────────────────────────── */ | |
| .lr-table-btns { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 9px; } | |
| #lr-tables { width: 100%; max-width: 640px; font-family: monospace; font-size: 13px; } | |
| .lr-hint { font-size: 12.5px; color: #646970; margin: 6px 0 0; } | |
| /* ── Step 3: guards ──────────────────────────────────────────────────────── */ | |
| .lr-guards { display: flex; flex-direction: column; gap: 10px; } | |
| .lr-guard { border: 1px solid #e0e0e0; border-radius: 5px; | |
| padding: 14px 16px; background: #fff; } | |
| .lr-guard-hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } | |
| .lr-lock { font-size: 15px; opacity: .7; } | |
| .lr-guard-hd strong { font-size: 14px; font-weight: 500; color: #1d2327; } | |
| .lr-default-on { margin-left: auto; background: #f0f6fc; color: #2271b1; | |
| font-size: 11.5px; font-weight: 500; padding: 2px 9px; | |
| border-radius: 20px; border: 1px solid #c5dff8; white-space: nowrap; } | |
| .lr-guard-what { font-size: 13px; color: #50575e; margin: 0 0 7px; | |
| line-height: 1.6; font-weight: 400; } | |
| .lr-guard-what em { font-style: normal; font-weight: 500; color: #1d2327; } | |
| .lr-guard-what code { font-size: 12px; } | |
| .lr-guard-break { font-size: 12.5px; color: #6b2020; margin: 0 0 10px; | |
| line-height: 1.55; padding: 7px 12px; | |
| background: #fdf4f4; border-radius: 3px; | |
| border-left: 2px solid #e0a0a0; font-weight: 400; } | |
| .lr-guard-break em { font-style: normal; font-weight: 500; } | |
| .lr-unlock { display: flex; align-items: center; gap: 7px; | |
| font-size: 13px; cursor: pointer; color: #646970; font-weight: 400; } | |
| .lr-unlock input { margin: 0; width: 15px; height: 15px; flex-shrink: 0; } | |
| .lr-unlock em { font-style: italic; color: #8c9099; } | |
| /* Opt-in block */ | |
| .lr-section-sub { margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0f0f1; } | |
| .lr-section-sub h3 { font-size: 12px; font-weight: 500; color: #50575e; | |
| text-transform: uppercase; letter-spacing: .05em; margin: 0 0 14px; } | |
| /* ── Step 4: run ─────────────────────────────────────────────────────────── */ | |
| .lr-run-section { background: #f6f7f7; } | |
| .lr-run-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } | |
| @media (max-width: 720px) { .lr-run-grid { grid-template-columns: 1fr; } } | |
| .lr-run-card { border: 1px solid #dcdcde; border-radius: 5px; padding: 16px 18px; | |
| background: #fff; display: flex; flex-direction: column; gap: 10px; } | |
| .lr-run-card h3 { margin: 0; font-size: 14px; font-weight: 500; } | |
| .lr-run-card p { margin: 0; font-size: 13px; color: #50575e; line-height: 1.6; } | |
| .lr-run-live { border-color: #d63638; } | |
| .lr-btn { padding: 0 18px !important; height: 30px !important; | |
| line-height: 28px !important; font-size: 13px !important; } | |
| .lr-btn-live { background: #d63638 !important; border-color: #b32d2e !important; color: #fff !important; } | |
| .lr-btn-live:hover:not(:disabled) { background: #b32d2e !important; } | |
| .lr-btn-live:disabled { opacity: .4; cursor: not-allowed !important; } | |
| .lr-confirm-row { display: flex; gap: 8px; align-items: center; } | |
| .lr-confirm-row input { width: 150px; flex: none; font-size: 13px; } | |
| .lr-confirm-row #btn-live { flex: none; padding: 0 14px !important; height: 30px !important; | |
| line-height: 28px !important; font-size: 13px !important; } | |
| /* ── Progress ────────────────────────────────────────────────────────────── */ | |
| #lr-progress-wrap { margin: 16px 0 8px; } | |
| #lr-progress { height: 3px; background: #e0e0e0; border-radius: 99px; overflow: hidden; } | |
| #lr-bar { height: 100%; width: 0; background: #2271b1; transition: width .2s; } | |
| #lr-status { font-size: 13px; color: #646970; margin-top: 6px; } | |
| /* ── Result boxes ────────────────────────────────────────────────────────── */ | |
| .lr-box { background: #fff; border: 1px solid #dcdcde; border-radius: 6px; | |
| padding: 16px 18px; margin-bottom: 12px; } | |
| .lr-box-title { margin: 0 0 12px; font-size: 14px; font-weight: 500; } | |
| .lr-box-title code { background: #f0f0f1; padding: 2px 7px; border-radius: 3px; font-size: 13px; } | |
| .lr-box-ok { border-color: #4ab866; background: #f0fbf4; } | |
| .lr-write-notice { font-size: 14px; padding: 12px 16px; } | |
| /* Panels */ | |
| .lr-panel { border-radius: 4px; padding: 12px 14px; margin-bottom: 10px; } | |
| .lr-panel:last-child { margin-bottom: 0; } | |
| .lr-panel-safe { background: #f2fbf2; border: 1px solid #c0dfc0; } | |
| .lr-panel-written { background: #f0f6fc; border: 1px solid #c0d8f0; } | |
| .lr-panel-guard { background: #f6f7f7; border: 1px solid #e0e0e0; } | |
| .lr-panel-title { margin: 0 0 10px; font-size: 13px; font-weight: 500; | |
| display: flex; align-items: center; gap: 8px; color: #1d2327; } | |
| .lr-cnt { display: inline-flex; align-items: center; justify-content: center; | |
| min-width: 20px; height: 20px; padding: 0 6px; background: rgba(0,0,0,.08); | |
| border-radius: 99px; font-size: 11px; font-weight: 600; } | |
| /* Result table */ | |
| .lr-tbl-wrap { overflow-x: auto; } | |
| .lr-tbl { width: 100%; font-size: 13px; border-collapse: collapse; } | |
| .lr-tbl th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; | |
| color: #646970; padding: 6px 12px; border-bottom: 1px solid #e0e0e0; | |
| text-align: left; white-space: nowrap; font-weight: 500; } | |
| .lr-tbl td { padding: 6px 12px; border-bottom: 1px solid #f0f0f1; vertical-align: top; } | |
| .lr-tbl tr:last-child td { border-bottom: none; } | |
| .lr-cnt-cell { text-align: center; color: #50575e; } | |
| .lr-opt-name { max-width: 180px; word-break: break-all; font-size: 12px; color: #50575e; } | |
| .lr-guard-reason { font-size: 12px; color: #8b2020; max-width: 240px; font-style: italic; } | |
| .lr-links a { font-size: 13px; } | |
| /* Checkbox column */ | |
| .lr-chk-col { width: 32px; text-align: center; padding-left: 8px !important; } | |
| .lr-chk-col input { margin: 0; cursor: pointer; width: 14px; height: 14px; } | |
| .lr-tbl tr.lr-row-skipped td { opacity: .35; text-decoration: line-through; } | |
| .lr-tbl tr.lr-row-skipped td.lr-chk-col { opacity: 1; text-decoration: none; } | |
| .lr-tbl tr.lr-row-written td { opacity: .5; } | |
| .lr-tbl tr.lr-row-written td.lr-chk-col { opacity: 1; } | |
| /* Match snippet */ | |
| .lr-snip { display: inline-block; background: #f6f7f7; padding: 2px 6px; | |
| border-radius: 3px; font-size: 12.5px; word-break: break-word; | |
| white-space: pre-wrap; max-width: 400px; } | |
| .lr-snip mark { background: #fff5c4; color: #503; padding: 0 1px; | |
| border-radius: 2px; outline: 1px solid #e2d48a; } | |
| .lr-muted { color: #646970; font-style: italic; font-size: 13px; } | |
| </style> | |
| <script> | |
| jQuery(function($){ | |
| var form = $('#lr-form'); | |
| var sel = $('#lr-tables'); | |
| var nonce = form.find('input[name="_wpnonce"]').val(); | |
| var allTbls = []; | |
| try { allTbls = JSON.parse(form.attr('data-tables')||'[]'); } catch(e){} | |
| var coreSfx = ['posts','postmeta','users','usermeta','options','comments', | |
| 'commentmeta','terms','term_taxonomy','term_relationships','links']; | |
| // Table quick-select | |
| $('#btn-all').on('click', function(){ sel.find('option').prop('selected',true); }); | |
| $('#btn-none').on('click', function(){ sel.find('option').prop('selected',false); }); | |
| $('#btn-core').on('click', function(){ | |
| sel.find('option').each(function(){ | |
| var t=this.value; | |
| $(this).prop('selected', coreSfx.some(function(s){ return t===s||t.endsWith('_'+s); })); | |
| }); | |
| }); | |
| $('#btn-posts').on('click', function(){ | |
| sel.find('option').each(function(){ | |
| var t=this.value; | |
| $(this).prop('selected', t.endsWith('posts')||t.endsWith('postmeta')); | |
| }); | |
| }); | |
| $('#btn-options').on('click', function(){ | |
| sel.find('option').each(function(){ | |
| $(this).prop('selected', this.value.endsWith('options')); | |
| }); | |
| }); | |
| // Unlock live button only when REPLACE is typed | |
| $('#lr-confirm').on('input', function(){ | |
| var ready = $(this).val().trim() === '<?php echo self::CONFIRM; ?>'; | |
| $('#btn-live').prop('disabled', !ready || !previewDone ); | |
| if(ready && previewDone) updateApplyLabel(); | |
| }); | |
| var previewDone = false; | |
| function countChecked(){ | |
| return $('#lr-results .lr-row-chk:checked').length; | |
| } | |
| function updateApplyLabel(){ | |
| var n = countChecked(); | |
| $('#btn-live').text( n > 0 ? 'Apply ' + n + ' selected row' + (n===1?'':'s') : 'Nothing selected' ); | |
| $('#btn-live').prop('disabled', n===0 || $('#lr-confirm').val().trim()!=='<?php echo self::CONFIRM; ?>'); | |
| } | |
| // Per-row checkbox toggle | |
| $(document).on('change', '.lr-row-chk', function(){ | |
| $(this).closest('tr').toggleClass('lr-row-skipped', !this.checked); | |
| updateApplyLabel(); | |
| }); | |
| // Select-all checkbox in thead | |
| $(document).on('change', '.lr-chk-all', function(){ | |
| var checked = this.checked; | |
| $(this).closest('table').find('.lr-row-chk').each(function(){ | |
| this.checked = checked; | |
| $(this).closest('tr').toggleClass('lr-row-skipped', !checked); | |
| }); | |
| updateApplyLabel(); | |
| }); | |
| function collectOpts(){ | |
| function cb(n){ return form.find('[name="'+n+'"]').is(':checked') ? 1 : 0; } | |
| return { | |
| search: form.find('[name="search"]').val()||'', | |
| replace: form.find('[name="replace"]').val()||'', | |
| regex: cb('regex'), | |
| case_sensitive: cb('case_sensitive'), | |
| skip_attachments: cb('skip_attachments'), | |
| skip_core_options: cb('skip_core_options'), | |
| skip_media_paths: cb('skip_media_paths'), | |
| skip_guid: cb('skip_guid'), | |
| skip_blobs: cb('skip_blobs'), | |
| allow_serialized: cb('allow_serialized'), | |
| scan_acf_options: cb('scan_acf_options'), | |
| exclude_meta_keys: form.find('[name="exclude_meta_keys"]').val()||'', | |
| limit: 500 | |
| }; | |
| } | |
| function runPreview(){ | |
| var search = form.find('[name="search"]').val().trim(); | |
| if(!search){ alert('Enter a search string first.'); return; } | |
| var selected = sel.val()||[]; | |
| var toScan = selected.length ? selected : allTbls.slice(); | |
| if(!toScan.length){ alert('No tables to scan.'); return; } | |
| var opts = collectOpts(); | |
| previewDone = false; | |
| $('#lr-results').empty(); | |
| $('#lr-progress-wrap').show(); | |
| $('#lr-bar').css('width','0%'); | |
| $('#lr-status').text('Starting…'); | |
| $('#btn-preview').prop('disabled', true); | |
| $('#btn-live').prop('disabled', true).text('Run a preview first'); | |
| var done=0, total=toScan.length; | |
| function tick(){ | |
| var pct=total?Math.round(done/total*100):0; | |
| $('#lr-bar').css('width',pct+'%'); | |
| $('#lr-status').text(done+' / '+total+' tables scanned'); | |
| } | |
| function next(){ | |
| if(done>=total){ | |
| $('#lr-status').text('Done — '+total+' tables scanned.'); | |
| $('#btn-preview').prop('disabled',false); | |
| previewDone = true; | |
| updateApplyLabel(); | |
| return; | |
| } | |
| var table=toScan[done]; | |
| $.post(ajaxurl,{ | |
| action:'lr_dbsr_scan', nonce:nonce, | |
| table:table, opts:JSON.stringify(opts), | |
| mode:'preview' | |
| },'json') | |
| .done(function(r){ | |
| if(r&&r.success&&r.data.html) $('#lr-results').append(r.data.html); | |
| else if(r&&!r.success){ | |
| $('#lr-results').append('<div class="lr-box lr-box-err"><p>'+ | |
| $('<s>').text(r.data||'Error').html()+'</p></div>'); | |
| } | |
| }) | |
| .fail(function(){ | |
| $('#lr-results').append('<div class="lr-box lr-box-err"><p>Request failed for <code>'+ | |
| $('<s>').text(table).html()+'</code></p></div>'); | |
| }) | |
| .always(function(){ done++; tick(); next(); }); | |
| } | |
| next(); | |
| } | |
| function applySelected(){ | |
| var rows = []; | |
| $('#lr-results .lr-row-chk:checked').each(function(){ | |
| rows.push($(this).data('write')); | |
| }); | |
| if(!rows.length){ alert('No rows selected.'); return; } | |
| if(!confirm('Write '+rows.length+' row'+(rows.length===1?'':'s')+' to the database?\n\nThis cannot be undone.')) return; | |
| $('#btn-live').prop('disabled',true).text('Writing…'); | |
| $.post(ajaxurl,{ | |
| action:'lr_dbsr_write', nonce:nonce, | |
| confirm: $('#lr-confirm').val().trim(), | |
| rows: JSON.stringify(rows) | |
| },'json') | |
| .done(function(r){ | |
| if(r&&r.success){ | |
| var w=r.data.written, e=r.data.errors; | |
| var msg = '✓ ' + w + ' row' + (w===1?'':'s') + ' written to the database.'; | |
| if(e) msg += ' ' + e + ' error' + (e===1?'':'s') + '.'; | |
| $('#lr-status').text(msg); | |
| // Mark written rows visually | |
| $('#lr-results .lr-row-chk:checked').each(function(){ | |
| $(this).prop('disabled',true).closest('tr').addClass('lr-row-written'); | |
| }); | |
| updateApplyLabel(); | |
| } else { | |
| alert('Error: '+(r&&r.data?r.data:'Unknown error')); | |
| updateApplyLabel(); | |
| } | |
| }) | |
| .fail(function(){ alert('Request failed.'); updateApplyLabel(); }); | |
| } | |
| $('#btn-preview').on('click', runPreview); | |
| $('#btn-live').on('click', function(){ | |
| if($(this).prop('disabled')) return; | |
| applySelected(); | |
| }); | |
| }); | |
| </script> | |
| <?php | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment