Skip to content

Instantly share code, notes, and snippets.

@arenagroove
Created March 4, 2026 05:57
Show Gist options
  • Select an option

  • Save arenagroove/feb470d2da7fc3d36d2e4e85fd08f0be to your computer and use it in GitHub Desktop.

Select an option

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.
<?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 &amp; 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 &amp; replace strings</h2>
<div class="lr-row2">
<label class="lr-label">
<span>Search for</span>
<span class="lr-sub">&nbsp;</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 &amp; 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> &amp; 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 &amp; 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