PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / mlsimport-dedupe.php

mlsimport-dedupe.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2, at includes/mlsimport-dedupe.php

353 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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