PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.0.4
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.0.4
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 / live / live-params.php

live-params.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.0.4, at includes/live/live-params.php

496 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Live mode: translate standalone filter params into a provider OData query.
4 *
5 * Pure functions — no WordPress, no DB, no network — so the translation is
6 * unit-testable per provider family. Input vocabulary is the standalone
7 * shortcode/block filter params (FILTER_ATTS); output is the query string
8 * appended to the MLS base URL. Ported from the AWS get-listings recipe
9 * (url_builders.py), which stays the reference for provider quirks.
10 *
11 * @package Mlsimport
12 */
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Build the query string for a live listings search, in the configured
20 * provider's dialect.
21 *
22 * Bridge speaks its native listings API (verified live vs Stellar 2026-07-03:
23 * OData hides Media for client tokens; the native API returns it inline).
24 * Every other Family 1 provider speaks generic RESO OData.
25 *
26 * @param array $params Standalone filter params (city, status, price_min, …).
27 * @param array $config Per-MLS config (type, expand, field_corellation, …).
28 * @return string Query string beginning with '?'.
29 */
30 function mlsimport_live_build_query( array $params, array $config ): string {
31 // Dispatch on provider type: Bridge speaks its native API, all others OData.
32 $type = isset( $config['type'] ) ? strtolower( (string) $config['type'] ) : 'bridge';
33 if ( 'bridge' === $type ) {
34 return mlsimport_live_build_query_bridge( $params, $config );
35 }
36 return mlsimport_live_build_query_odata( $params, $config );
37 }
38
39 /**
40 * The shared translation vocabularies: standalone param => RESO field.
41 * Note the deliberate cross-map: the standalone property_type column holds
42 * RESO PropertySubType values and listing_type holds RESO PropertyType
43 * values (see the standalone reso-map).
44 *
45 * @return array{lists:array,numeric:array,sortable:array}
46 */
47 function mlsimport_live_param_vocabulary(): array {
48 return array(
49 'lists' => array(
50 'city' => 'City',
51 'county' => 'CountyOrParish',
52 'state' => 'StateOrProvince',
53 'zip' => 'PostalCode',
54 'property_type' => 'PropertySubType',
55 'listing_type' => 'PropertyType',
56 ),
57 'numeric' => array(
58 'price_min' => array( 'ListPrice', 'ge' ),
59 'price_max' => array( 'ListPrice', 'le' ),
60 'beds' => array( 'BedroomsTotal', 'ge' ),
61 'baths' => array( 'BathroomsTotalDecimal', 'ge' ),
62 'sqft_min' => array( 'LivingArea', 'ge' ),
63 'sqft_max' => array( 'LivingArea', 'le' ),
64 'lot_min' => array( 'LotSizeSquareFeet', 'ge' ),
65 'lot_max' => array( 'LotSizeSquareFeet', 'le' ),
66 'year_min' => array( 'YearBuilt', 'ge' ),
67 'year_max' => array( 'YearBuilt', 'le' ),
68 'hoa_max' => array( 'AssociationFee', 'le' ),
69 'dom_max' => array( 'DaysOnMarket', 'le' ),
70 'garage_min' => array( 'GarageSpaces', 'ge' ),
71 'stories' => array( 'StoriesTotal', 'ge' ),
72 ),
73 'sortable' => array(
74 'price' => 'ListPrice',
75 'bedrooms' => 'BedroomsTotal',
76 'bathrooms' => 'BathroomsTotalDecimal',
77 'living_area' => 'LivingArea',
78 'lot_size' => 'LotSizeSquareFeet',
79 'year_built' => 'YearBuilt',
80 'list_date' => 'ListingContractDate',
81 'days_on_market' => 'DaysOnMarket',
82 'modification_timestamp' => 'ModificationTimestamp',
83 ),
84 );
85 }
86
87 /**
88 * Bridge native listings API dialect: Field.in= for lists, Field.gte/.lte
89 * for ranges, sortBy+order, limit/offset, box=lng,lat,lng,lat. Media comes
90 * inline — no expand parameter exists or is needed.
91 *
92 * @param array $params Standalone filter params.
93 * @param array $config Per-MLS config.
94 * @return string Query string beginning with '?'.
95 */
96 function mlsimport_live_build_query_bridge( array $params, array $config ): string {
97 // $alias maps each canonical RESO field to the provider's own field name.
98 $vocab = mlsimport_live_param_vocabulary();
99 $alias = static function ( string $field ) use ( $config ): string {
100 return mlsimport_live_field_alias( $field, $config );
101 };
102
103 // Each query fragment is collected here and &-joined at the end.
104 $pairs = array();
105
106 // List-valued filters -> Field.in=a,b,c (any value matches).
107 foreach ( $vocab['lists'] as $param => $field ) {
108 if ( ! empty( $params[ $param ] ) ) {
109 // Coerce to a trimmed, non-empty string list.
110 $values = array_filter( array_map( 'trim', array_map( 'strval', (array) $params[ $param ] ) ), 'strlen' );
111 if ( array() !== $values ) {
112 // Canonical order: any-match lists mean the same query in any
113 // order, and one spelling means one cache entry.
114 sort( $values );
115 $pairs[] = $alias( $field ) . '.in=' . implode( ',', array_map( 'rawurlencode', $values ) );
116 }
117 }
118 }
119
120 // An explicit ListingKey set IS the filter — no status baseline, or a
121 // hand-picked Pending/Closed listing would silently vanish. Sorted for
122 // the cache; callers re-apply their input order after the fetch.
123 if ( ! empty( $params['keys'] ) && is_array( $params['keys'] ) ) {
124 // Explicit key set: filter on ListingKey and add no status baseline.
125 $keys = array_filter( array_map( 'trim', array_map( 'strval', $params['keys'] ) ), 'strlen' );
126 sort( $keys );
127 $pairs[] = $alias( 'ListingKey' ) . '.in=' . implode( ',', array_map( 'rawurlencode', $keys ) );
128 } else {
129 // Baseline: a search without an explicit status only shows Active listings.
130 $status = ! empty( $params['status'] ) ? (array) $params['status'] : array( 'Active' );
131 $status = array_filter( array_map( 'trim', array_map( 'strval', $status ) ), 'strlen' );
132 sort( $status );
133 $pairs[] = $alias( 'StandardStatus' ) . '.in=' . implode( ',', array_map( 'rawurlencode', $status ) );
134 }
135
136 // Single-value subdivision as an equality match.
137 if ( ! empty( $params['subdivision'] ) && ! is_array( $params['subdivision'] ) ) {
138 $pairs[] = $alias( 'SubdivisionName' ) . '=' . rawurlencode( (string) $params['subdivision'] );
139 }
140
141 // Minimum list date, only when it's a valid YYYY-MM-DD.
142 if ( ! empty( $params['list_date_min'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $params['list_date_min'] ) ) {
143 $pairs[] = $alias( 'ListingContractDate' ) . '.gte=' . $params['list_date_min'];
144 }
145
146 // Numeric ranges -> Field.gte/.lte, one pair per set numeric param.
147 foreach ( $vocab['numeric'] as $param => $rule ) {
148 if ( isset( $params[ $param ] ) && '' !== $params[ $param ] && is_numeric( $params[ $param ] ) ) {
149 // rule[1] is the comparator direction (ge->gte, le->lte).
150 $suffix = 'ge' === $rule[1] ? 'gte' : 'lte';
151 $pairs[] = $alias( $rule[0] ) . '.' . $suffix . '=' . ( 0 + $params[ $param ] );
152 }
153 }
154
155 // Map viewport: box takes lng,lat corner pairs (verified order).
156 if ( isset( $params['lat_min'], $params['lat_max'], $params['lng_min'], $params['lng_max'] ) ) {
157 $pairs[] = 'box=' . ( 0 + $params['lng_min'] ) . ',' . ( 0 + $params['lat_min'] )
158 . ',' . ( 0 + $params['lng_max'] ) . ',' . ( 0 + $params['lat_max'] );
159 }
160
161 // Sorting: whitelisted columns via sortBy/order; ListingKey keeps paging stable.
162 $orderby = isset( $params['orderby'] ) ? (string) $params['orderby'] : '';
163 if ( isset( $vocab['sortable'][ $orderby ] ) ) {
164 // Known sort column: emit sortBy + direction (default desc).
165 $order = isset( $params['order'] ) && 'ASC' === strtoupper( (string) $params['order'] ) ? 'asc' : 'desc';
166 $pairs[] = 'sortBy=' . $vocab['sortable'][ $orderby ];
167 $pairs[] = 'order=' . $order;
168 } else {
169 // Unknown/no sort: fall back to the stable ListingKey order.
170 $pairs[] = 'sortBy=ListingKey';
171 }
172
173 // Bridge's native API rejects limit > 200 with HTTP 400 (verified vs
174 // Stellar 2026-07-03).
175 $limit = isset( $params['limit'] ) ? max( 1, (int) $params['limit'] ) : 0;
176 $limit = min( $limit, 200 );
177 if ( $limit > 0 ) {
178 // limit + offset paging (offset only past page 1).
179 $pairs[] = 'limit=' . $limit;
180 $page = isset( $params['page'] ) ? max( 1, (int) $params['page'] ) : 1;
181 if ( $page > 1 ) {
182 $pairs[] = 'offset=' . ( ( $page - 1 ) * $limit );
183 }
184 }
185
186 // Join every fragment into the final ?a&b&c query string.
187 return '?' . implode( '&', $pairs );
188 }
189
190 /**
191 * Generic RESO OData dialect ($filter/$orderby/$top/$skip/$count/$expand).
192 *
193 * @param array $params Standalone filter params (city, status, price_min, …).
194 * @param array $config Per-MLS config (type, expand, field_corellation, …).
195 * @return string Query string beginning with '?'.
196 */
197 function mlsimport_live_build_query_odata( array $params, array $config ): string {
198 // $filter accumulates the OData $filter clauses (each ends ' and ');
199 // $alias resolves canonical RESO names to the provider's own names.
200 $type = isset( $config['type'] ) ? strtolower( (string) $config['type'] ) : '';
201 $filter = '';
202 $alias = static function ( string $field ) use ( $config ): string {
203 return mlsimport_live_field_alias( $field, $config );
204 };
205
206 // List-valued params (any value matches). Note the deliberate cross-map:
207 // the standalone property_type column holds RESO PropertySubType values and
208 // listing_type holds RESO PropertyType values (see the standalone reso-map).
209 $vocab = mlsimport_live_param_vocabulary();
210 foreach ( $vocab['lists'] as $param => $field ) {
211 if ( 'rapattoni' === $type && 'listing_type' === $param ) {
212 // Rapattoni takes the class as a plain Class= parameter, not a
213 // PropertyType $filter (same rule as the AWS URL builder).
214 continue;
215 }
216 if ( ! empty( $params[ $param ] ) ) {
217 $filter .= mlsimport_live_filter_list_segment( $alias( $field ), (array) $params[ $param ] );
218 }
219 }
220
221 if ( ! empty( $params['subdivision'] ) && ! is_array( $params['subdivision'] ) ) {
222 $filter .= mlsimport_live_filter_list_segment( $alias( 'SubdivisionName' ), array( (string) $params['subdivision'] ) );
223 }
224
225 if ( ! empty( $params['list_date_min'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $params['list_date_min'] ) ) {
226 $filter .= '(' . $alias( 'ListingContractDate' ) . ' ge ' . $params['list_date_min'] . ') and ';
227 }
228
229 // Numeric ranges + the bbox coordinate ranges (OData pushes them as plain
230 // Latitude/Longitude comparisons; the bridge dialect uses box= instead).
231 $numeric = array_merge(
232 $vocab['numeric'],
233 array(
234 'lat_min' => array( 'Latitude', 'ge' ),
235 'lat_max' => array( 'Latitude', 'le' ),
236 'lng_min' => array( 'Longitude', 'ge' ),
237 'lng_max' => array( 'Longitude', 'le' ),
238 )
239 );
240 foreach ( $numeric as $param => $rule ) {
241 if ( isset( $params[ $param ] ) && '' !== $params[ $param ] && is_numeric( $params[ $param ] ) ) {
242 $filter .= '(' . $alias( $rule[0] ) . ' ' . $rule[1] . ' ' . ( 0 + $params[ $param ] ) . ') and ';
243 }
244 }
245
246 // An explicit ListingKey set IS the filter — no status baseline, or a
247 // hand-picked Pending/Closed listing would silently vanish.
248 if ( ! empty( $params['keys'] ) && is_array( $params['keys'] ) ) {
249 $filter .= mlsimport_live_filter_list_segment( $alias( 'ListingKey' ), $params['keys'] );
250 } else {
251 // Baseline: a search without an explicit status only shows Active listings
252 // (mirrors the Import Task default).
253 $status = ! empty( $params['status'] ) ? (array) $params['status'] : array( 'Active' );
254 $filter .= mlsimport_live_filter_list_segment( $alias( 'StandardStatus' ), $status );
255 }
256
257 // Drop the trailing ' and ' left by the last appended clause.
258 $filter = preg_replace( '/ and $/', '', $filter );
259
260 // Assemble the query string, starting with provider-specific flags.
261 $query = '?';
262 if ( 'trestle' === $type ) {
263 // Trestle serves spaced enum labels with PrettyEnums=true — the same
264 // shape the saved enums (and so the search dropdowns) use, so filters
265 // must speak it too.
266 $query .= '&PrettyEnums=true';
267 }
268 if ( 'rapattoni' === $type && ! empty( $params['listing_type'] ) ) {
269 $query .= '&Class=' . rawurlencode( (string) current( (array) $params['listing_type'] ) );
270 }
271 if ( 'brightmls' === $type ) {
272 // BrightMLS rejects $expand=Media — media comes from the separate
273 // BrightMedia endpoint (same rule as the AWS URL builder).
274 $query .= '&$format=json';
275 } elseif ( ! empty( $config['expand'] ) ) {
276 $query .= '&$expand=' . $config['expand'];
277 }
278
279 // Rapattoni has no ListingKey column to order by — its stable fallback is
280 // ListingKeyNumeric (same rule as the AWS URL builder).
281 $orderby = mlsimport_live_orderby( $params );
282 if ( 'rapattoni' === $type && 'ListingKey' === $orderby ) {
283 $orderby = 'ListingKeyNumeric';
284 }
285 // Always request $orderby + $count (the total drives paging/clustering).
286 $query .= '&$orderby=' . $orderby;
287 $query .= '&$count=true';
288
289 // Page size ($top): 0 means "unset" until the provider rules below apply.
290 $limit = isset( $params['limit'] ) ? max( 1, (int) $params['limit'] ) : 0;
291 if ( $limit <= 0 && in_array( $type, array( 'realcomp', 'brightmls' ), true ) ) {
292 // Realcomp requires an explicit page size; BrightMLS's ~7M-row feed
293 // times out without one. Both default to the AWS builder's 25.
294 $limit = 25;
295 }
296 if ( 'rmls' === $type ) {
297 // RMLS hard-caps $top at 25.
298 $limit = $limit > 0 ? min( $limit, 25 ) : 25;
299 }
300 if ( $limit > 0 ) {
301 // $top + $skip paging (skip only past page 1).
302 $query .= '&$top=' . $limit;
303 $page = isset( $params['page'] ) ? max( 1, (int) $params['page'] ) : 1;
304 if ( $page > 1 ) {
305 $query .= '&$skip=' . ( ( $page - 1 ) * $limit );
306 }
307 }
308
309 // Append the built $filter (wrapped) only when there is one.
310 if ( '' !== $filter ) {
311 $query .= '&$filter=(' . $filter . ')';
312 }
313
314 return $query;
315 }
316
317 /**
318 * The $orderby expression: the standalone SORTABLE columns mapped to their
319 * RESO fields (same mapping the reso-map uses for the flat table), with
320 * ListingKey as the stable fallback so paging order is always deterministic.
321 *
322 * @param array $params Standalone filter params (orderby, order).
323 * @return string e.g. 'ListPrice desc' or 'ListingKey'.
324 */
325 function mlsimport_live_orderby( array $params ): string {
326 $sortable = mlsimport_live_param_vocabulary()['sortable'];
327
328 // Unknown/absent sort column: the stable ListingKey fallback.
329 $orderby = isset( $params['orderby'] ) ? (string) $params['orderby'] : '';
330 if ( ! isset( $sortable[ $orderby ] ) ) {
331 return 'ListingKey';
332 }
333
334 // Known column: its RESO field + direction (default desc).
335 $order = isset( $params['order'] ) && 'ASC' === strtoupper( (string) $params['order'] ) ? 'asc' : 'desc';
336 return $sortable[ $orderby ] . ' ' . $order;
337 }
338
339 /**
340 * Resolve a RESO field name to the provider's own name via field_corellation
341 * (the per-MLS alias map from the mld_details config; misspelling canonical).
342 *
343 * @param string $field Canonical RESO field name.
344 * @param array $config Per-MLS config; field_corellation is a JSON string.
345 * @return string Provider field name (unchanged when no alias applies).
346 */
347 function mlsimport_live_field_alias( string $field, array $config ): string {
348 // No alias map configured: the field name passes through unchanged.
349 if ( empty( $config['field_corellation'] ) ) {
350 return $field;
351 }
352 // Memoize the decoded map on its raw JSON so repeated calls parse once.
353 static $memo_raw = null;
354 static $memo_map = array();
355
356 $raw = (string) $config['field_corellation'];
357 if ( $raw !== $memo_raw ) {
358 $decoded = json_decode( $raw, true );
359 $memo_raw = $raw;
360 $memo_map = is_array( $decoded ) ? $decoded : array();
361 }
362 $map = $memo_map;
363 // Use the alias only when it's a real non-empty string; else the original.
364 if ( is_array( $map ) && isset( $map[ $field ] ) && is_string( $map[ $field ] ) && '' !== $map[ $field ] ) {
365 return $map[ $field ];
366 }
367 return $field;
368 }
369
370 /**
371 * The BrightMedia query for one chunk of listing keys. BrightMLS serves media
372 * from a separate endpoint (api_media_url); keys go in unquoted, ordered by
373 * record + display order — the same request the AWS media fetch builds.
374 *
375 * @param string[] $keys ListingKeys for this chunk (max 100 per request).
376 * @return string Query string beginning with '?'.
377 */
378 function mlsimport_live_brightmls_media_query( array $keys ): string {
379 // The media columns the import needs from BrightMedia.
380 $select = 'MediaKey,ResourceRecordKey,MediaCategory,MediaType,'
381 . 'MediaDisplayOrder,PreferredPhotoYN,MediaURL,MediaURLHiRes,'
382 . 'MediaModificationTimestamp';
383
384 // Filter to this key chunk, ordered by record then display order (unquoted
385 // keys, per the AWS media fetch).
386 return '?$filter=ResourceRecordKey in (' . implode( ',', array_map( 'strval', $keys ) ) . ')'
387 . '&$orderby=ResourceRecordKey,MediaDisplayOrder'
388 . '&$select=' . $select
389 . '&$format=json';
390 }
391
392 /**
393 * Does a viewport map request carry visitor search filters? The v1.1 cluster
394 * index is unfiltered by design (active listings, whole MLS), so a filtered
395 * map keeps the zoom-in state — its counts must never be wrong.
396 *
397 * @param array $args Whitelisted filter args (already cleaned of empties).
398 * @return bool True when any key beyond the viewport/paging set is present.
399 */
400 function mlsimport_live_map_has_filters( array $args ): bool {
401 // Viewport + paging keys carry no visitor filter intent.
402 $neutral = array( 'lat_min', 'lat_max', 'lng_min', 'lng_max', 'zoom', 'orderby', 'order', 'limit', 'page' );
403
404 // Any key outside that neutral set means the map is filtered.
405 return array() !== array_diff( array_keys( $args ), $neutral );
406 }
407
408 /**
409 * Quantize a filter set's viewport bbox OUTWARD to three decimals (~110m —
410 * mins floor, maxes ceil, the box only grows) BEFORE the query is built, so
411 * nearby map pans produce the identical query and reuse one cache entry
412 * instead of firing a fresh MLS request per pixel.
413 *
414 * @param array $params Filter params, possibly carrying a bbox.
415 * @return array The params with any bbox edges quantized.
416 */
417 function mlsimport_live_quantize_bbox( array $params ): array {
418 // Mins floor, maxes ceil, so quantizing only ever grows the box.
419 $edges = array(
420 'lat_min' => 'floor',
421 'lat_max' => 'ceil',
422 'lng_min' => 'floor',
423 'lng_max' => 'ceil',
424 );
425 // Snap each present, numeric edge to 3 decimals via *1000/round/÷1000.
426 foreach ( $edges as $edge => $fn ) {
427 if ( isset( $params[ $edge ] ) && is_numeric( $params[ $edge ] ) ) {
428 $params[ $edge ] = number_format( $fn( (float) $params[ $edge ] * 1000 ) / 1000, 3, '.', '' );
429 }
430 }
431 return $params;
432 }
433
434 /**
435 * The /clusters request params for a viewport. The bbox is quantized OUTWARD
436 * to two decimals (~1km — mins floor, maxes ceil), so the box only grows,
437 * edge listings are never dropped, and nearby pans share one cached answer.
438 *
439 * @param array $args Filter args carrying the viewport bbox.
440 * @param int $zoom Map zoom level.
441 * @param int $mls_id The site's MLS id.
442 * @return array|null Ordered param array, or null without a full viewport.
443 */
444 function mlsimport_live_clusters_params( array $args, int $zoom, int $mls_id ) {
445 // A partial viewport (any edge missing/non-numeric) can't be asked of the index.
446 foreach ( array( 'lat_min', 'lat_max', 'lng_min', 'lng_max' ) as $edge ) {
447 if ( ! isset( $args[ $edge ] ) || ! is_numeric( $args[ $edge ] ) ) {
448 return null;
449 }
450 }
451
452 // mls_id + the bbox quantized OUTWARD to 2dp (~1km) + zoom.
453 return array(
454 'mls_id' => $mls_id,
455 'lat_min' => number_format( floor( (float) $args['lat_min'] * 100 ) / 100, 2, '.', '' ),
456 'lat_max' => number_format( ceil( (float) $args['lat_max'] * 100 ) / 100, 2, '.', '' ),
457 'lng_min' => number_format( floor( (float) $args['lng_min'] * 100 ) / 100, 2, '.', '' ),
458 'lng_max' => number_format( ceil( (float) $args['lng_max'] * 100 ) / 100, 2, '.', '' ),
459 'zoom' => $zoom,
460 );
461 }
462
463 /**
464 * One list-valued $filter segment: (Field eq 'A' or Field eq 'B') and .
465 *
466 * @param string $field RESO/OData field name.
467 * @param array $values Accepted values (any match).
468 * @return string Segment ending in ' and ', or '' when no usable values.
469 */
470 function mlsimport_live_filter_list_segment( string $field, array $values ): string {
471 // Trim to non-empty string values.
472 $clean = array();
473 foreach ( $values as $value ) {
474 $value = trim( (string) $value );
475 if ( '' !== $value ) {
476 $clean[] = $value;
477 }
478 }
479 // No usable values: no segment.
480 if ( array() === $clean ) {
481 return '';
482 }
483
484 // Canonical order: an any-match list means the same query in any order,
485 // and one spelling means one cache entry.
486 sort( $clean );
487
488 // One `Field eq 'value'` clause each (single quotes doubled to escape).
489 $clauses = array();
490 foreach ( $clean as $value ) {
491 $clauses[] = $field . " eq '" . str_replace( "'", "''", $value ) . "'";
492 }
493 // OR the clauses together and terminate with ' and ' for concatenation.
494 return '(' . implode( ' or ', $clauses ) . ') and ';
495 }
496