| Server IP : 104.21.21.239 / Your IP : 216.73.217.143 Web Server : Apache/2.4.68 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-69-123.ec2.internal 6.1.176-223.369.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Fri Jul 24 13:34:27 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /home/tgnew/wp-content/plugins/em-location-deduper/ |
Upload File : |
<?php
/**
* Plugin Name: EM Location Deduper
* Description: De-duplicate Events Manager locations with stronger normalization, multiple strategies (name/address/coords/slug), multisite awareness, and diagnostics preview.
* Version: 1.1.0
* Author: Sam Later - LatCom Systems
* License: GPL-2.0+
*/
if (!defined('ABSPATH')) exit;
class EM_Location_Deduper {
const MENU_SLUG = 'em-location-deduper';
const NONCE = 'em_location_deduper_nonce';
const LOCK_KEY = 'em_location_deduper_lock';
public function __construct() {
add_action('admin_menu', [$this, 'register_page']);
}
public function register_page() {
add_management_page(
'EM Location Deduper',
'EM Location Deduper',
'manage_options',
self::MENU_SLUG,
[$this, 'render_page']
);
}
private function tables() {
global $wpdb;
$loc = $wpdb->prefix . 'em_locations';
$evt = $wpdb->prefix . 'em_events';
return [$loc, $evt];
}
private function column_exists($table, $col) {
global $wpdb;
$sql = $wpdb->prepare("SHOW COLUMNS FROM {$table} LIKE %s", $col);
return (bool) $wpdb->get_var($sql);
}
private function acquire_lock($ttl = 120) {
if (get_transient(self::LOCK_KEY)) return false;
set_transient(self::LOCK_KEY, 1, $ttl);
return true;
}
private function release_lock() {
delete_transient(self::LOCK_KEY);
}
private function checkbox_field( $name, $checked, $text ) {
// Hidden fallback so unchecked boxes submit "0"
echo '<input type="hidden" name="' . esc_attr( $name ) . '" value="0">';
echo '<label><input type="checkbox" name="' . esc_attr( $name ) . '" value="1"' . checked( $checked, true, false ) . '> ' . esc_html( $text ) . '</label>';
}
// Stronger normalization: lowercase, trim, collapse spaces, strip punctuation, remove accents.
private function normalize_strong($val) {
$s = (string) $val;
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// remove accents
if (function_exists('iconv')) {
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($t !== false) $s = $t;
}
$s = strtolower($s);
// replace non-breaking space with regular
$s = str_replace("\xC2\xA0", ' ', $s);
// remove punctuation (keep digits/letters/spaces)
$s = preg_replace('/[^a-z0-9\s]/', ' ', $s);
// collapse whitespace
$s = trim(preg_replace('/\s+/', ' ', $s));
return $s;
}
private function make_key($row, $strategy) {
// Pre-normalized values
$name = $this->normalize_strong($row->location_name ?? '');
$address = $this->normalize_strong($row->location_address ?? '');
$town = $this->normalize_strong($row->location_town ?? '');
$state = $this->normalize_strong($row->location_state ?? '');
$postcode = $this->normalize_strong($row->location_postcode ?? '');
$country = $this->normalize_strong($row->location_country ?? '');
$slug = $this->normalize_strong($row->location_slug ?? '');
switch ($strategy) {
case 'strict': return implode('|', [$name, $address, $town, $state, $postcode, $country]);
case 'medium': return implode('|', [$name, $address]);
case 'loose': return strtolower(trim((string)$row->location_name));
case 'slug': return $slug;
case 'coords':
$lat = isset($row->location_latitude) ? round((float)$row->location_latitude, 5) : '';
$lng = isset($row->location_longitude) ? round((float)$row->location_longitude, 5) : '';
return $lat . '|' . $lng;
default: return $name;
}
}
public function render_page() {
if (!current_user_can('manage_options')) wp_die('Insufficient permissions.');
$defaults = [
'dry_run' => '1',
'strategy' => 'medium', // safer default
'merge_fields' => '1',
'trash_cpt_posts' => '1',
'limit' => '0',
'preview' => '1',
'preview_max' => '25',
];
$state = wp_parse_args($_POST ?: [], $defaults);
$ran = false;
$report = [];
$preview = [];
if (!empty($_POST) && check_admin_referer(self::NONCE)) {
if (isset($_POST['do_preview'])) {
$preview = $this->diagnostics_preview([
'strategy' => sanitize_text_field($state['strategy']),
'preview_max' => max(1, (int)$state['preview_max']),
]);
} else {
$ran = true;
$report = $this->run([
'dry_run' => !empty($state['dry_run']),
'strategy' => sanitize_text_field($state['strategy']),
'merge_fields' => !empty($state['merge_fields']),
'trash_cpt_posts' => !empty($state['trash_cpt_posts']),
'limit' => max(0, (int)$state['limit']),
]);
}
}
?>
<div class="wrap">
<h1>EM Location Deduper</h1>
<p>Find and merge duplicate Events Manager locations. Start by running a Preview to confirm the groups look right.</p>
<form method="post">
<?php wp_nonce_field(self::NONCE); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="strategy">Matching strategy</label></th>
<td>
<select name="strategy" id="strategy">
<option value="strict" <?php selected($state['strategy'], 'strict'); ?>>Strict — name+address+town+state+postcode+country</option>
<option value="medium" <?php selected($state['strategy'], 'medium'); ?>>Medium — name+address</option>
<option value="loose" <?php selected($state['strategy'], 'loose'); ?>>Loose — name only</option>
<option value="coords" <?php selected($state['strategy'], 'coords'); ?>>Coordinates — latitude/longitude (rounded)</option>
<option value="slug" <?php selected($state['strategy'], 'slug'); ?>>Slug — location_slug</option>
</select>
<p class="description">If “no duplicates”, try <em>medium</em>, then <em>loose</em> or <em>coords</em>.</p>
</td>
</tr>
<tr>
<th scope="row">Dry run</th>
<td>
<?php $this->checkbox_field( 'dry_run', ! empty( $state['dry_run'] ), 'Preview changes without modifying the database' );?>
</td>
</tr>
<tr>
<th scope="row">Merge missing fields</th>
<td>
<?php $this->checkbox_field( 'merge_fields', ! empty( $state['merge_fields'] ), 'Fill empty canonical fields from duplicates (address, town, state, postcode, country, lat/lng, phone, url)' );?>
</td>
</tr>
<tr>
<th scope="row">Trash duplicate CPT posts</th>
<td>
<?php $this->checkbox_field( 'trash_cpt_posts', ! empty( $state['trash_cpt_posts'] ), 'If a duplicate location has a linked post_id, move that post to trash' );?>
</td>
</tr>
<tr>
<th scope="row"><label for="limit">Max groups to process</label></th>
<td>
<input type="number" name="limit" id="limit" min="0" step="1" value="<?php echo esc_attr($state['limit']); ?>" placeholder="0 (no limit)">
</td>
</tr>
</table>
<p>
<button class="button button-secondary" name="do_preview" value="1">Preview duplicates</button>
<label style="margin-left:10px;">Show
<input type="number" name="preview_max" min="1" step="1" value="<?php echo esc_attr($state['preview_max']); ?>" style="width:80px;"> groups
</label>
<?php submit_button('Run Deduper', 'primary', '', false); ?>
</p>
</form>
<?php if (!empty($preview)) : ?>
<hr><h2>Diagnostics preview</h2>
<p><strong>Strategy:</strong> <?php echo esc_html($preview['strategy']); ?> — <strong>Groups found:</strong> <?php echo intval($preview['groups_found']); ?></p>
<?php if (!empty($preview['groups'])): ?>
<ol>
<?php foreach ($preview['groups'] as $g): ?>
<li>
<strong>Key:</strong> <?php echo esc_html($g['key']); ?> —
<strong>Members:</strong> <?php echo intval($g['count']); ?><br>
<code><?php echo esc_html(implode(" | ", $g['sample_labels'])); ?></code>
</li>
<?php endforeach; ?>
</ol>
<?php else: ?>
<p>No duplicate groups found with this strategy.</p>
<?php endif; ?>
<?php endif; ?>
<?php if (!empty($report)) : ?>
<hr><h2>Report</h2>
<?php echo $this->render_report($report); ?>
<?php endif; ?>
</div>
<?php
}
private function render_report($r) {
ob_start();
if (!empty($r['errors'])) {
echo '<div class="notice notice-error"><p><strong>Errors:</strong></p><ul>';
foreach ($r['errors'] as $e) echo '<li>' . esc_html($e) . '</li>';
echo '</ul></div>';
}
echo '<p><strong>Dry run:</strong> ' . (!empty($r['dry_run']) ? 'Yes' : 'No') . '</p>';
echo '<p><strong>Strategy:</strong> ' . esc_html($r['strategy']) . '</p>';
echo '<p><strong>Groups found:</strong> ' . intval($r['groups_found']) . '</p>';
echo '<p><strong>Groups processed:</strong> ' . intval($r['groups_processed']) . '</p>';
echo '<p><strong>Events reassigned:</strong> ' . intval($r['events_reassigned']) . '</p>';
echo '<p><strong>Locations deleted:</strong> ' . intval($r['locations_deleted']) . '</p>';
echo '<p><strong>Canonical fields updated:</strong> ' . intval($r['canon_updates']) . '</p>';
if (!empty($r['details'])) {
echo '<h3>Details</h3><ol>';
foreach ($r['details'] as $d) echo '<li><pre style="white-space:pre-wrap;">' . esc_html($d) . '</pre></li>';
echo '</ol>';
}
return ob_get_clean();
}
private function diagnostics_preview($args) {
global $wpdb;
list($em_locations, $em_events) = $this->tables();
$strategy = in_array($args['strategy'], ['strict','medium','loose','coords','slug'], true) ? $args['strategy'] : 'medium';
$limit = max(1, (int)$args['preview_max']);
// Fetch needed columns (including slug + blog_id if present)
$has_blog = $this->column_exists($em_locations, 'blog_id');
$cols = "location_id, post_id, location_name, location_address, location_town, location_state, location_postcode, location_country, location_slug, location_latitude, location_longitude" . ($has_blog ? ", blog_id" : "");
$where = $has_blog ? $wpdb->prepare("WHERE blog_id IN (0, %d)", get_current_blog_id()) : "";
$rows = $wpdb->get_results("SELECT {$cols} FROM {$em_locations} {$where}");
error_log( 'Found rows: ' . count($rows) );
$groups = [];
foreach ($rows as $row) {
$key = $this->make_key($row, $strategy);
if ($key === '' || $key === '|' || $key === '||') continue;
$label = sprintf('#%d %s / %s', $row->location_id, $row->location_name, $row->location_address);
$groups[$key]['items'][] = $label;
}
$dupes = [];
foreach ($groups as $key => $g) {
$count = count($g['items']);
if ($count > 1) {
$dupes[] = [
'key' => $key,
'count' => $count,
'sample_labels' => array_slice($g['items'], 0, 3)
];
}
}
usort($dupes, function($a,$b){ return $b['count'] <=> $a['count']; });
return [
'strategy' => $strategy,
'groups_found' => count($dupes),
'groups' => array_slice($dupes, 0, $limit),
];
}
private function run($args) {
global $wpdb;
list($em_locations, $em_events) = $this->tables();
$dry_run = !empty($args['dry_run']);
$strategy = in_array($args['strategy'], ['strict','medium','loose','coords','slug'], true) ? $args['strategy'] : 'medium';
$merge_fields = !empty($args['merge_fields']);
$trash_posts = !empty($args['trash_cpt_posts']);
$limit_groups = max(0, (int)$args['limit']);
$r = [
'dry_run' => $dry_run,
'strategy' => $strategy,
'groups_found' => 0,
'groups_processed' => 0,
'events_reassigned' => 0,
'locations_deleted' => 0,
'canon_updates' => 0,
'details' => [],
'errors' => [],
];
if (!$this->acquire_lock()) {
$r['errors'][] = 'Another dedupe job is running. Try again shortly.';
return $r;
}
try {
$has_blog = $this->column_exists($em_locations, 'blog_id');
$cols = "location_id, post_id, location_name, location_address, location_town, location_state, location_postcode, location_region, location_country, location_latitude, location_longitude, location_slug" . ($has_blog ? ", blog_id" : "");
$where = $has_blog ? $wpdb->prepare("WHERE blog_id IN (0, %d)", get_current_blog_id()) : "";
$rows = $wpdb->get_results("SELECT {$cols} FROM {$em_locations} {$where}");
error_log( 'Found rows: ' . count($rows) );
error_log( 'SQL: ' . "SELECT {$cols} FROM {$em_locations} {$where}" );
if (!is_array($rows)) throw new RuntimeException('Failed to read em_locations.');
// Group by key
$groups = [];
foreach ($rows as $row) {
$key = $this->make_key($row, $strategy);
if ($key === '' || $key === '|' || $key === '||') continue;
$groups[$key][] = $row;
}
// Keep only groups with >1
$dupe_groups = array_filter($groups, function($list){ return count($list) > 1; });
$r['groups_found'] = count($dupe_groups);
$processed = 0;
foreach ($dupe_groups as $key => $list) {
if ($limit_groups && $processed >= $limit_groups) break;
// Count events per location
$stats = [];
foreach ($list as $row) {
$stats[$row->location_id] = (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM {$em_events} WHERE location_id = %d", $row->location_id)
);
}
// First try to find slug without numeric suffixes
$canonical = null;
$slug_pattern = '/-\d+$/'; // matches -1, -2 etc. at end of slug
$ix = -1;
foreach ($list as $row) {
$ix++;
$slug = isset($row->location_slug) ? $row->location_slug : '';
if ($slug !== '' && !preg_match($slug_pattern, $slug)) {
// Exact base slug found, pick this and stop
$canonical = $row;
$dupes = $list;
unset($dupes[$ix]);
break;
}
}
// If no base slug found, fall back to most events / lowest ID
if (!$canonical) {
usort($list, function($a,$b) use ($stats){
$cmp = $stats[$b->location_id] <=> $stats[$a->location_id];
return $cmp !== 0 ? $cmp : ($a->location_id <=> $b->location_id);
});
$canonical = $list[0];
$dupes = array_slice($list, 1);
}
$detail = [];
$detail[] = "Group key: {$key}";
$detail[] = "Canonical: #{$canonical->location_id} \"{$canonical->location_name}\" (events: {$stats[$canonical->location_id]})";
// Reassign events
$dupe_ids = array_map(function($d){ return (int)$d->location_id; }, $dupes);
$reassigned = 0;
if (!empty($dupe_ids)) {
$in = implode(',', array_fill(0, count($dupe_ids), '%d'));
$event_ids = $wpdb->get_col($wpdb->prepare("SELECT event_id FROM {$em_events} WHERE location_id IN ($in)", $dupe_ids));
$reassigned = count($event_ids);
if (!$dry_run && $reassigned > 0) {
$params = array_merge([(int)$canonical->location_id], $dupe_ids);
$wpdb->query($wpdb->prepare("UPDATE {$em_events} SET location_id = %d WHERE location_id IN ($in)", $params));
// Update post meta for the event CPT
$post_ids = $wpdb->get_col(
$wpdb->prepare("SELECT post_id FROM {$wpdb->prefix}em_events WHERE location_id = %d", $canonical->location_id)
);
foreach ( $post_ids as $event_post_id ) {
update_post_meta( $event_post_id, '_location_id', (int) $canonical->location_id );
// Optional: also sync name/slug if you want them fresh
update_post_meta( $event_post_id, '_location_name', $canonical->location_name );
update_post_meta( $event_post_id, '_location_slug', $canonical->location_slugslug );
}
}
}
$r['events_reassigned'] += $reassigned;
$detail[] = "Events reassigned: {$reassigned}";
// Merge fields
$updated_fields = 0;
if ($merge_fields) {
$fields = [
'location_address','location_town','location_state','location_postcode','location_country',
'location_latitude','location_longitude'
];
$updates = [];
foreach ($fields as $f) {
$cur = $canonical->{$f};
if ($cur === null || $cur === '' || $cur === '0') {
foreach ($dupes as $d) {
$val = $d->{$f};
if ($val !== null && $val !== '' && $val !== '0') { $updates[$f] = $val; break; }
}
}
}
if (!empty($updates) && !$dry_run) {
$set = [];
$vals = [];
foreach ($updates as $k => $v) { $set[] = "{$k} = %s"; $vals[] = $v; }
$vals[] = (int)$canonical->location_id;
$wpdb->query($wpdb->prepare("UPDATE {$em_locations} SET " . implode(', ', $set) . " WHERE location_id = %d", $vals));
$updated_fields = count($updates);
$r['canon_updates'] += $updated_fields;
}
}
if ($updated_fields) $detail[] = "Canonical fields merged: {$updated_fields}";
// Trash CPT posts and delete dupe rows
$deleted = 0;
foreach ($dupes as $d) {
$detail[] = sprintf('Duplicate: #%d "%s" (events: %d)', $d->location_id, $d->location_name, $stats[$d->location_id]);
if (!$dry_run && $trash_posts && !empty($d->post_id)) {
$post = get_post((int)$d->post_id);
if ($post && $post->post_status !== 'trash') wp_trash_post($post->ID);
}
if (!$dry_run) {
$wpdb->delete($em_locations, ['location_id' => (int)$d->location_id], ['%d']);
$deleted++;
}
}
$r['locations_deleted'] += $deleted;
if ($deleted) $detail[] = "Duplicates deleted: {$deleted}";
$r['details'][] = implode("\n", $detail);
$processed++;
}
$r['groups_processed'] = $processed;
} catch (Throwable $e) {
$r['errors'][] = $e->getMessage();
} finally {
$this->release_lock();
}
return $r;
}
}
new EM_Location_Deduper();