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 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 6.0.7 All 35 releases
mlsimport / includes / live / live-params.php

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

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