| 1 |
<?php |
| 2 |
/** |
| 3 |
* Cross-connection listing dedupe mechanics (issue #282, decision #267). |
| 4 |
* |
| 5 |
* WHY THIS FILE EXISTS |
| 6 |
* -------------------- |
| 7 |
* With multiple MLS connections (spec #273) two feeds can carry the SAME |
| 8 |
* physical property under different ListingKeys (Miami AOR + BeachesMLS). |
| 9 |
* Both copies import and update normally under their own (mls_id, listing_key) |
| 10 |
* identity (#278); this module makes the site SHOW exactly one of them: |
| 11 |
* |
| 12 |
* - Same property = same normalized physical address — built by |
| 13 |
* mlsimport_dedupe_address_key() (mlsimport-dedupe-address.php) and |
| 14 |
* stamped on every imported listing as the 'mlsimport_address_key' meta. |
| 15 |
* - Winner = the copy from the highest-priority connection (priority 1 |
| 16 |
* first, the user-set drag order on the Connections screen). |
| 17 |
* - Every losing copy gets the 'mlsimport_duplicate_of' meta pointing at |
| 18 |
* the winning post and is excluded from all front-end queries (search, |
| 19 |
* sliders, maps, feeds) — both the WP_Query surfaces (pre_get_posts) |
| 20 |
* and the standalone flat-table reads (its shared FROM clause). |
| 21 |
* - Hiding is NEVER deleting: when the winner is deleted or stops being |
| 22 |
* published (trash, draft, an excluded status leaving the market), its |
| 23 |
* group is re-evaluated and the loser is promoted (flag cleared) so the |
| 24 |
* property never vanishes while still listed. |
| 25 |
* |
| 26 |
* The whole mechanic is ONE evaluator, mlsimport_dedupe_evaluate(), run from |
| 27 |
* every seam that can change a group: an import write, a post removal (WP |
| 28 |
* delete/status hooks, plus explicit calls on the two intentional raw-SQL |
| 29 |
* delete paths that bypass WP hooks), and a priority reorder. Listings |
| 30 |
* imported before this module exists gain their address key on their next |
| 31 |
* import touch (any outcome, including 'unchanged') — dedupe activates as |
| 32 |
* feeds sync. |
| 33 |
* |
| 34 |
* @since 7.2.0 |
| 35 |
* @package Mlsimport |
| 36 |
*/ |
| 37 |
|
| 38 |
if ( ! defined( 'ABSPATH' ) ) { |
| 39 |
exit; |
| 40 |
} |
| 41 |
|
| 42 |
/** |
| 43 |
* Pick the winning post among the copies of one physical property. |
| 44 |
* |
| 45 |
* Rule (decision #267): the copy from the highest-priority connection wins |
| 46 |
* (priority 1 beats 2). Determinism guarantees, so re-evaluation never flaps: |
| 47 |
* a connection missing from the registry ranks below every registered one; |
| 48 |
* a priority tie breaks on the smaller mls_id; copies from the same |
| 49 |
* connection break on the smaller post id. |
| 50 |
* |
| 51 |
* @param array<int, int> $candidates Candidate posts: post_id => mls_id. |
| 52 |
* @param array<int, int> $priorities Registry priorities: mls_id => priority. |
| 53 |
* @return int Winning post id (0 only for an empty candidate list). |
| 54 |
*/ |
| 55 |
function mlsimport_dedupe_pick_winner( array $candidates, array $priorities ): int { |
| 56 |
$winner = 0; |
| 57 |
$best_rank = null; |
| 58 |
foreach ( $candidates as $post_id => $mls_id ) { |
| 59 |
// Rank tuple compared lexicographically: priority, mls_id, post_id. |
| 60 |
$rank = array( $priorities[ $mls_id ] ?? PHP_INT_MAX, (int) $mls_id, (int) $post_id ); |
| 61 |
if ( null === $best_rank || $rank < $best_rank ) { |
| 62 |
$best_rank = $rank; |
| 63 |
$winner = (int) $post_id; |
| 64 |
} |
| 65 |
} |
| 66 |
return $winner; |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Re-evaluate winner/loser flags for every published copy of one address. |
| 71 |
* |
| 72 |
* Step by step: |
| 73 |
* 1. Collect the published posts carrying this address key (minus one being |
| 74 |
* deleted, passed as $exclude_id) and each copy's mlsimport_mls_id stamp. |
| 75 |
* 2. Copies from fewer than two connections mean no cross-connection |
| 76 |
* duplicate exists (detection runs against OTHER connections only, #282): |
| 77 |
* clear every flag — this is exactly how a surviving loser is PROMOTED |
| 78 |
* when the winner disappears. |
| 79 |
* 3. Otherwise pick the winner from the registry priorities and flag every |
| 80 |
* copy from every other connection with 'mlsimport_duplicate_of' pointing |
| 81 |
* at the winning post; the winning connection's copies are unflagged. |
| 82 |
* |
| 83 |
* Flags are meta only — hiding is never deleting. |
| 84 |
* |
| 85 |
* @param string $address_key Normalized address key of the group. |
| 86 |
* @param string $post_type Property post type the copies live under. |
| 87 |
* @param int $exclude_id Post being deleted right now (still in the DB). |
| 88 |
* @return void |
| 89 |
*/ |
| 90 |
function mlsimport_dedupe_evaluate( string $address_key, string $post_type, int $exclude_id = 0 ): void { |
| 91 |
if ( '' === $address_key || '' === $post_type ) { |
| 92 |
return; |
| 93 |
} |
| 94 |
|
| 95 |
// Step 1: the group's published copies and their owning connections. |
| 96 |
// Deliberately direct SQL, not WP_Query: this module's own front-end |
| 97 |
// exclusion (pre_get_posts below) hides flagged losers from property |
| 98 |
// queries, and the evaluator MUST see them — otherwise a hidden loser |
| 99 |
// could never be promoted when its winner disappears. |
| 100 |
global $wpdb; |
| 101 |
$sql = $wpdb->prepare( |
| 102 |
"SELECT P.ID, MLS.meta_value AS mls_id |
| 103 |
FROM {$wpdb->posts} P |
| 104 |
INNER JOIN {$wpdb->postmeta} ADR |
| 105 |
ON ADR.post_id = P.ID |
| 106 |
AND ADR.meta_key = 'mlsimport_address_key' |
| 107 |
AND ADR.meta_value = %s |
| 108 |
LEFT JOIN {$wpdb->postmeta} MLS |
| 109 |
ON MLS.post_id = P.ID |
| 110 |
AND MLS.meta_key = 'mlsimport_mls_id' |
| 111 |
WHERE P.post_type = %s AND P.post_status = 'publish' AND P.ID != %d", |
| 112 |
$address_key, |
| 113 |
$post_type, |
| 114 |
$exclude_id |
| 115 |
); |
| 116 |
$candidates = array(); |
| 117 |
foreach ( (array) $wpdb->get_results( $sql ) as $row ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 118 |
$candidates[ (int) $row->ID ] = (int) $row->mls_id; |
| 119 |
} |
| 120 |
if ( empty( $candidates ) ) { |
| 121 |
return; |
| 122 |
} |
| 123 |
|
| 124 |
// Step 2: one connection (or none) => no duplicate => everyone visible. |
| 125 |
if ( count( array_unique( $candidates ) ) < 2 ) { |
| 126 |
foreach ( array_keys( $candidates ) as $post_id ) { |
| 127 |
delete_post_meta( $post_id, 'mlsimport_duplicate_of' ); |
| 128 |
} |
| 129 |
return; |
| 130 |
} |
| 131 |
|
| 132 |
// Step 3: flag every copy that is not from the winning connection. |
| 133 |
$priorities = array(); |
| 134 |
foreach ( Mlsimport_Connections::all() as $mls_id => $record ) { |
| 135 |
$priorities[ (int) $mls_id ] = (int) $record['priority']; |
| 136 |
} |
| 137 |
$winner = mlsimport_dedupe_pick_winner( $candidates, $priorities ); |
| 138 |
$winner_mls = $candidates[ $winner ]; |
| 139 |
foreach ( $candidates as $post_id => $mls_id ) { |
| 140 |
if ( $mls_id === $winner_mls ) { |
| 141 |
delete_post_meta( $post_id, 'mlsimport_duplicate_of' ); |
| 142 |
} else { |
| 143 |
update_post_meta( $post_id, 'mlsimport_duplicate_of', $winner ); |
| 144 |
} |
| 145 |
} |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Stamp the address key and re-evaluate after one import write. |
| 150 |
* |
| 151 |
* Hooked on the Stored Listing Write success/warning events, which fire only |
| 152 |
* after persistence committed. Step by step: |
| 153 |
* 1. Writes that changed the post — created / updated / saved-with-warnings — |
| 154 |
* always re-settle their group. An 'unchanged' outcome matters only when |
| 155 |
* the post predates this module and carries NO key yet: stamping it then |
| 156 |
* is how legacy listings join dedupe without waiting for an MLS change. |
| 157 |
* ('deleted' went through wp_delete_post, whose hook re-evaluates.) |
| 158 |
* 2. Stamp the fresh normalized address key on the post. |
| 159 |
* 3. When the key CHANGED, re-evaluate the OLD group too — the copy that |
| 160 |
* left it may have been that group's winner. |
| 161 |
* 4. Re-evaluate the new key's group. |
| 162 |
* |
| 163 |
* @param array<string, mixed> $result Public Stored Listing Write result. |
| 164 |
* @param array<string, mixed> $property Incoming raw property payload. |
| 165 |
* @return void |
| 166 |
*/ |
| 167 |
function mlsimport_dedupe_after_write( $result, $property ): void { |
| 168 |
// Step 1: only outcomes that can change a dedupe group proceed. |
| 169 |
$listing_id = (int) ( $result['listing_id'] ?? 0 ); |
| 170 |
$outcome = (string) ( $result['outcome'] ?? '' ); |
| 171 |
$previous = $listing_id > 0 ? (string) get_post_meta( $listing_id, 'mlsimport_address_key', true ) : ''; |
| 172 |
$writes = in_array( $outcome, array( 'created', 'updated', 'saved-with-warnings' ), true ); |
| 173 |
$backfill = 'unchanged' === $outcome && '' === $previous; |
| 174 |
if ( $listing_id <= 0 || ( ! $writes && ! $backfill ) ) { |
| 175 |
return; |
| 176 |
} |
| 177 |
|
| 178 |
$post_type = (string) get_post_type( $listing_id ); |
| 179 |
$key = mlsimport_dedupe_address_key( is_array( $property ) ? $property : array() ); |
| 180 |
|
| 181 |
// Step 2: stamp (or clear) the identity this evaluation runs under. |
| 182 |
if ( '' === $key ) { |
| 183 |
delete_post_meta( $listing_id, 'mlsimport_address_key' ); |
| 184 |
} else { |
| 185 |
update_post_meta( $listing_id, 'mlsimport_address_key', $key ); |
| 186 |
} |
| 187 |
|
| 188 |
// Step 3: a changed address releases this copy from its old group. |
| 189 |
if ( '' !== $previous && $previous !== $key ) { |
| 190 |
mlsimport_dedupe_evaluate( $previous, $post_type ); |
| 191 |
} |
| 192 |
|
| 193 |
// Step 4: settle the group the copy belongs to now. |
| 194 |
mlsimport_dedupe_evaluate( $key, $post_type ); |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* Re-evaluate a deleted listing's group so its loser is promoted. |
| 199 |
* |
| 200 |
* before_delete_post fires while the post row and meta still exist, so the |
| 201 |
* key is readable and the doomed post is excluded from the evaluation by id. |
| 202 |
* Non-listing posts carry no address key and return immediately. |
| 203 |
* |
| 204 |
* @param int $post_id Post being permanently deleted. |
| 205 |
* @return void |
| 206 |
*/ |
| 207 |
function mlsimport_dedupe_on_delete( $post_id ): void { |
| 208 |
$post_id = (int) $post_id; |
| 209 |
$key = (string) get_post_meta( $post_id, 'mlsimport_address_key', true ); |
| 210 |
if ( '' !== $key ) { |
| 211 |
mlsimport_dedupe_evaluate( $key, (string) get_post_type( $post_id ), $post_id ); |
| 212 |
} |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Re-evaluate when a listing enters or leaves the published set. |
| 217 |
* |
| 218 |
* One transition_post_status hook covers every status seam at once: trash, |
| 219 |
* untrash, quick-edit to draft/pending, scheduled publish. Only transitions |
| 220 |
* that cross the 'publish' boundary matter — the evaluator's candidate set |
| 221 |
* is published posts, so an un-published winner promotes its loser here and |
| 222 |
* a re-published copy re-enters its group. Fires after the status is saved, |
| 223 |
* so the plain evaluation already sees the correct set. Import-time inserts |
| 224 |
* pass through harmlessly: their address key is not stamped yet. |
| 225 |
* |
| 226 |
* @param string $new_status Status after the transition. |
| 227 |
* @param string $old_status Status before the transition. |
| 228 |
* @param WP_Post $post The post transitioning. |
| 229 |
* @return void |
| 230 |
*/ |
| 231 |
function mlsimport_dedupe_on_status_change( $new_status, $old_status, $post ): void { |
| 232 |
if ( $new_status === $old_status || ( 'publish' !== $new_status && 'publish' !== $old_status ) ) { |
| 233 |
return; |
| 234 |
} |
| 235 |
$key = (string) get_post_meta( (int) $post->ID, 'mlsimport_address_key', true ); |
| 236 |
if ( '' !== $key ) { |
| 237 |
mlsimport_dedupe_evaluate( $key, (string) $post->post_type ); |
| 238 |
} |
| 239 |
} |
| 240 |
|
| 241 |
/** |
| 242 |
* Re-evaluate every currently flagged group after a priority reorder. |
| 243 |
* |
| 244 |
* Every multi-connection group carries at least one flagged loser (the |
| 245 |
* import-time evaluation guarantees it), so the flagged posts enumerate |
| 246 |
* exactly the groups a priority change can re-decide. Deterministic: the |
| 247 |
* same order always produces the same winners (#282 acceptance). |
| 248 |
* |
| 249 |
* @return void |
| 250 |
*/ |
| 251 |
function mlsimport_dedupe_reevaluate_flagged(): void { |
| 252 |
$flagged = get_posts( |
| 253 |
array( |
| 254 |
'post_type' => 'any', |
| 255 |
'post_status' => 'any', |
| 256 |
'posts_per_page' => -1, |
| 257 |
'fields' => 'ids', |
| 258 |
'no_found_rows' => true, |
| 259 |
'meta_key' => 'mlsimport_duplicate_of', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key |
| 260 |
// This read enumerates hidden posts by definition — it must never |
| 261 |
// be filtered by the front-end exclusion below. |
| 262 |
'mlsimport_include_hidden' => true, |
| 263 |
) |
| 264 |
); |
| 265 |
$groups = array(); |
| 266 |
foreach ( $flagged as $post_id ) { |
| 267 |
$key = (string) get_post_meta( (int) $post_id, 'mlsimport_address_key', true ); |
| 268 |
$type = (string) get_post_type( (int) $post_id ); |
| 269 |
if ( '' !== $key && '' !== $type ) { |
| 270 |
$groups[ $type . '|' . $key ] = array( $key, $type ); |
| 271 |
} |
| 272 |
} |
| 273 |
foreach ( $groups as $group ) { |
| 274 |
mlsimport_dedupe_evaluate( $group[0], $group[1] ); |
| 275 |
} |
| 276 |
} |
| 277 |
|
| 278 |
/** |
| 279 |
* Hide flagged duplicates from every front-end WP_Query surface. |
| 280 |
* |
| 281 |
* Applies to any query targeting a property post type EXCEPT: |
| 282 |
* - queries that opt out with 'mlsimport_include_hidden' — the plugin's |
| 283 |
* internal reads that MUST see hidden copies (the import identity |
| 284 |
* lookup, telemetry sampling, the standalone reindex); |
| 285 |
* - wp-admin SCREEN queries (is_admin without AJAX) — the admin listings |
| 286 |
* table deliberately shows hidden duplicates (#267; badge deferred). |
| 287 |
* AJAX queries stay covered because theme search/slider/map surfaces |
| 288 |
* commonly fetch through admin-ajax; |
| 289 |
* - singular queries — hiding is about result LISTS, a direct permalink |
| 290 |
* still resolves. |
| 291 |
* |
| 292 |
* "Targeting a property post type" means an explicit post_type match, or a |
| 293 |
* taxonomy archive whose queried taxonomy is registered to a property post |
| 294 |
* type (those main queries carry an empty post_type). Registered at a LATE |
| 295 |
* priority so themes that inject their post type from their own |
| 296 |
* pre_get_posts callbacks are still seen. The clause is a NOT EXISTS meta |
| 297 |
* condition AND-combined with whatever meta_query the surface already set. |
| 298 |
* |
| 299 |
* @param WP_Query $query The query being prepared. |
| 300 |
* @return void |
| 301 |
*/ |
| 302 |
function mlsimport_dedupe_exclude_hidden( $query ): void { |
| 303 |
if ( |
| 304 |
$query->get( 'mlsimport_include_hidden' ) |
| 305 |
|| ( is_admin() && ! wp_doing_ajax() ) |
| 306 |
|| $query->is_singular() |
| 307 |
) { |
| 308 |
return; |
| 309 |
} |
| 310 |
$property = array( 'estate_property', 'property', 'mlsimport_property' ); |
| 311 |
$queried = array_filter( (array) ( $query->get( 'post_type' ) ?: array() ) ); |
| 312 |
$targets = ! empty( array_intersect( $queried, $property ) ); |
| 313 |
if ( ! $targets && empty( $queried ) && $query->is_tax() && isset( $query->tax_query->queries ) ) { |
| 314 |
// Property-taxonomy archives (city, category, ...) query with an |
| 315 |
// empty post_type; resolve the taxonomy's owning post types instead. |
| 316 |
foreach ( (array) $query->tax_query->queries as $clause ) { |
| 317 |
$taxonomy = get_taxonomy( (string) ( $clause['taxonomy'] ?? '' ) ); |
| 318 |
if ( $taxonomy && ! empty( array_intersect( (array) $taxonomy->object_type, $property ) ) ) { |
| 319 |
$targets = true; |
| 320 |
break; |
| 321 |
} |
| 322 |
} |
| 323 |
} |
| 324 |
if ( ! $targets ) { |
| 325 |
return; |
| 326 |
} |
| 327 |
|
| 328 |
$not_flagged = array( |
| 329 |
'key' => 'mlsimport_duplicate_of', |
| 330 |
'compare' => 'NOT EXISTS', |
| 331 |
); |
| 332 |
$existing = $query->get( 'meta_query' ); |
| 333 |
$query->set( |
| 334 |
'meta_query', |
| 335 |
empty( $existing ) |
| 336 |
? array( $not_flagged ) |
| 337 |
: array( |
| 338 |
'relation' => 'AND', |
| 339 |
$existing, |
| 340 |
$not_flagged, |
| 341 |
) |
| 342 |
); |
| 343 |
} |
| 344 |
|
| 345 |
// Import writes re-settle their group; WP-level removals and publish-boundary |
| 346 |
// status changes promote survivors; late priority lets theme pre_get_posts |
| 347 |
// callbacks set their post type before the exclusion looks at it. |
| 348 |
add_action( 'mlsimport_stored_listing_write_success', 'mlsimport_dedupe_after_write', 10, 2 ); |
| 349 |
add_action( 'mlsimport_stored_listing_write_warning', 'mlsimport_dedupe_after_write', 10, 2 ); |
| 350 |
add_action( 'before_delete_post', 'mlsimport_dedupe_on_delete' ); |
| 351 |
add_action( 'transition_post_status', 'mlsimport_dedupe_on_status_change', 10, 3 ); |
| 352 |
add_action( 'pre_get_posts', 'mlsimport_dedupe_exclude_hidden', 999 ); |
| 353 |
|