PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.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 All 37 releases
mlsimport / includes / live / live-params.php

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

492 lines 19.9 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 // The "MLS #" search box: the public MLS number as an equality match.
140 if ( ! empty( $params['listing_id'] ) && ! is_array( $params['listing_id'] ) ) {
141 $pairs[] = $alias( 'ListingId' ) . '=' . rawurlencode( trim( (string) $params['listing_id'] ) );
142 }
143
144 // Minimum list date, only when it's a valid YYYY-MM-DD.
145 if ( ! empty( $params['list_date_min'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $params['list_date_min'] ) ) {
146 $pairs[] = $alias( 'ListingContractDate' ) . '.gte=' . $params['list_date_min'];
147 }
148
149 // Numeric ranges -> Field.gte/.lte, one pair per set numeric param.
150 foreach ( $vocab['numeric'] as $param => $rule ) {
151 if ( isset( $params[ $param ] ) && '' !== $params[ $param ] && is_numeric( $params[ $param ] ) ) {
152 // rule[1] is the comparator direction (ge->gte, le->lte).
153 $suffix = 'ge' === $rule[1] ? 'gte' : 'lte';
154 $pairs[] = $alias( $rule[0] ) . '.' . $suffix . '=' . ( 0 + $params[ $param ] );
155 }
156 }
157
158 // Map viewport: box takes lng,lat corner pairs (verified order).
159 if ( isset( $params['lat_min'], $params['lat_max'], $params['lng_min'], $params['lng_max'] ) ) {
160 $pairs[] = 'box=' . ( 0 + $params['lng_min'] ) . ',' . ( 0 + $params['lat_min'] )
161 . ',' . ( 0 + $params['lng_max'] ) . ',' . ( 0 + $params['lat_max'] );
162 }
163
164 // Sorting: whitelisted columns via sortBy/order; ListingKey keeps paging stable.
165 $orderby = isset( $params['orderby'] ) ? (string) $params['orderby'] : '';
166 if ( isset( $vocab['sortable'][ $orderby ] ) ) {
167 // Known sort column: emit sortBy + direction (default desc).
168 $order = isset( $params['order'] ) && 'ASC' === strtoupper( (string) $params['order'] ) ? 'asc' : 'desc';
169 $pairs[] = 'sortBy=' . $vocab['sortable'][ $orderby ];
170 $pairs[] = 'order=' . $order;
171 } else {
172 // Unknown/no sort: fall back to the stable ListingKey order.
173 $pairs[] = 'sortBy=ListingKey';
174 }
175
176 // Bridge's native API rejects limit > 200 with HTTP 400 (verified vs
177 // Stellar 2026-07-03).
178 $limit = isset( $params['limit'] ) ? max( 1, (int) $params['limit'] ) : 0;
179 $limit = min( $limit, 200 );
180 if ( $limit > 0 ) {
181 // limit + offset paging (offset only past page 1).
182 $pairs[] = 'limit=' . $limit;
183 $page = isset( $params['page'] ) ? max( 1, (int) $params['page'] ) : 1;
184 if ( $page > 1 ) {
185 $pairs[] = 'offset=' . ( ( $page - 1 ) * $limit );
186 }
187 }
188
189 // Join every fragment into the final ?a&b&c query string.
190 return '?' . implode( '&', $pairs );
191 }
192
193 /**
194 * Generic RESO OData dialect ($filter/$orderby/$top/$skip/$count/$expand).
195 *
196 * @param array $params Standalone filter params (city, status, price_min, …).
197 * @param array $config Per-MLS config (type, expand, field_corellation, …).
198 * @return string Query string beginning with '?'.
199 */
200 function mlsimport_live_build_query_odata( array $params, array $config, array $rules = array() ): string {
201 // $filter accumulates the OData $filter clauses (each ends ' and ');
202 // $alias resolves canonical RESO names to the provider's own names.
203 $filter = '';
204 $rules = array_merge(
205 array(
206 'skip_listing_type_filter' => false,
207 'pretty_enums' => false,
208 'class_parameter' => false,
209 'format_json' => false,
210 'allow_expand' => true,
211 'default_orderby' => 'ListingKey',
212 'default_limit' => 0,
213 'max_limit' => 0,
214 ),
215 $rules
216 );
217 $alias = static function ( string $field ) use ( $config ): string {
218 return mlsimport_live_field_alias( $field, $config );
219 };
220
221 // List-valued params (any value matches). Note the deliberate cross-map:
222 // the standalone property_type column holds RESO PropertySubType values and
223 // listing_type holds RESO PropertyType values (see the standalone reso-map).
224 $vocab = mlsimport_live_param_vocabulary();
225 foreach ( $vocab['lists'] as $param => $field ) {
226 if ( $rules['skip_listing_type_filter'] && 'listing_type' === $param ) {
227 // Rapattoni takes the class as a plain Class= parameter, not a
228 // PropertyType $filter (same rule as the AWS URL builder).
229 continue;
230 }
231 if ( ! empty( $params[ $param ] ) ) {
232 $filter .= mlsimport_live_filter_list_segment( $alias( $field ), (array) $params[ $param ] );
233 }
234 }
235
236 if ( ! empty( $params['subdivision'] ) && ! is_array( $params['subdivision'] ) ) {
237 $filter .= mlsimport_live_filter_list_segment( $alias( 'SubdivisionName' ), array( (string) $params['subdivision'] ) );
238 }
239
240 // The "MLS #" search box: `ListingId eq '<number>'` (the helper trims the
241 // value and doubles single quotes, so the visitor's text cannot break out).
242 if ( ! empty( $params['listing_id'] ) && ! is_array( $params['listing_id'] ) ) {
243 $filter .= mlsimport_live_filter_list_segment( $alias( 'ListingId' ), array( (string) $params['listing_id'] ) );
244 }
245
246 if ( ! empty( $params['list_date_min'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $params['list_date_min'] ) ) {
247 $filter .= '(' . $alias( 'ListingContractDate' ) . ' ge ' . $params['list_date_min'] . ') and ';
248 }
249
250 // Numeric ranges + the bbox coordinate ranges (OData pushes them as plain
251 // Latitude/Longitude comparisons; the bridge dialect uses box= instead).
252 $numeric = array_merge(
253 $vocab['numeric'],
254 array(
255 'lat_min' => array( 'Latitude', 'ge' ),
256 'lat_max' => array( 'Latitude', 'le' ),
257 'lng_min' => array( 'Longitude', 'ge' ),
258 'lng_max' => array( 'Longitude', 'le' ),
259 )
260 );
261 foreach ( $numeric as $param => $rule ) {
262 if ( isset( $params[ $param ] ) && '' !== $params[ $param ] && is_numeric( $params[ $param ] ) ) {
263 $filter .= '(' . $alias( $rule[0] ) . ' ' . $rule[1] . ' ' . ( 0 + $params[ $param ] ) . ') and ';
264 }
265 }
266
267 // An explicit ListingKey set IS the filter — no status baseline, or a
268 // hand-picked Pending/Closed listing would silently vanish.
269 if ( ! empty( $params['keys'] ) && is_array( $params['keys'] ) ) {
270 $filter .= mlsimport_live_filter_list_segment( $alias( 'ListingKey' ), $params['keys'] );
271 } else {
272 // Baseline: a search without an explicit status only shows Active listings
273 // (mirrors the Import Task default).
274 $status = ! empty( $params['status'] ) ? (array) $params['status'] : array( 'Active' );
275 $filter .= mlsimport_live_filter_list_segment( $alias( 'StandardStatus' ), $status );
276 }
277
278 // Drop the trailing ' and ' left by the last appended clause.
279 $filter = preg_replace( '/ and $/', '', $filter );
280
281 // Assemble the query string, starting with provider-specific flags.
282 $query = '?';
283 if ( $rules['pretty_enums'] ) {
284 // Trestle serves spaced enum labels with PrettyEnums=true — the same
285 // shape the saved enums (and so the search dropdowns) use, so filters
286 // must speak it too.
287 $query .= '&PrettyEnums=true';
288 }
289 if ( $rules['class_parameter'] && ! empty( $params['listing_type'] ) ) {
290 $query .= '&Class=' . rawurlencode( (string) current( (array) $params['listing_type'] ) );
291 }
292 if ( $rules['format_json'] ) {
293 // BrightMLS rejects $expand=Media — media comes from the separate
294 // BrightMedia endpoint (same rule as the AWS URL builder).
295 $query .= '&$format=json';
296 } elseif ( $rules['allow_expand'] && ! empty( $config['expand'] ) ) {
297 $query .= '&$expand=' . $config['expand'];
298 }
299
300 // Rapattoni has no ListingKey column to order by — its stable fallback is
301 // ListingKeyNumeric (same rule as the AWS URL builder).
302 $orderby = mlsimport_live_orderby( $params );
303 if ( 'ListingKey' === $orderby ) {
304 $orderby = $rules['default_orderby'];
305 }
306 // Always request $orderby + $count (the total drives paging/clustering).
307 $query .= '&$orderby=' . $orderby;
308 $query .= '&$count=true';
309
310 // Page size ($top): 0 means "unset" until the provider rules below apply.
311 $limit = isset( $params['limit'] ) ? max( 1, (int) $params['limit'] ) : 0;
312 if ( $limit <= 0 && $rules['default_limit'] > 0 ) {
313 $limit = (int) $rules['default_limit'];
314 }
315 if ( $limit > 0 && $rules['max_limit'] > 0 ) {
316 $limit = min( $limit, (int) $rules['max_limit'] );
317 }
318 if ( $limit > 0 ) {
319 // $top + $skip paging (skip only past page 1).
320 $query .= '&$top=' . $limit;
321 $page = isset( $params['page'] ) ? max( 1, (int) $params['page'] ) : 1;
322 if ( $page > 1 ) {
323 $query .= '&$skip=' . ( ( $page - 1 ) * $limit );
324 }
325 }
326
327 // Append the built $filter (wrapped) only when there is one.
328 if ( '' !== $filter ) {
329 $query .= '&$filter=(' . $filter . ')';
330 }
331
332 return $query;
333 }
334
335 /**
336 * The $orderby expression: the standalone SORTABLE columns mapped to their
337 * RESO fields (same mapping the reso-map uses for the flat table), with
338 * ListingKey as the stable fallback so paging order is always deterministic.
339 *
340 * @param array $params Standalone filter params (orderby, order).
341 * @return string e.g. 'ListPrice desc' or 'ListingKey'.
342 */
343 function mlsimport_live_orderby( array $params ): string {
344 $sortable = mlsimport_live_param_vocabulary()['sortable'];
345
346 // Unknown/absent sort column: the stable ListingKey fallback.
347 $orderby = isset( $params['orderby'] ) ? (string) $params['orderby'] : '';
348 if ( ! isset( $sortable[ $orderby ] ) ) {
349 return 'ListingKey';
350 }
351
352 // Known column: its RESO field + direction (default desc).
353 $order = isset( $params['order'] ) && 'ASC' === strtoupper( (string) $params['order'] ) ? 'asc' : 'desc';
354 return $sortable[ $orderby ] . ' ' . $order;
355 }
356
357 /**
358 * Resolve a RESO field name to the provider's own name via field_corellation
359 * (the per-MLS alias map from the mld_details config; misspelling canonical).
360 *
361 * @param string $field Canonical RESO field name.
362 * @param array $config Per-MLS config; field_corellation is a JSON string.
363 * @return string Provider field name (unchanged when no alias applies).
364 */
365 function mlsimport_live_field_alias( string $field, array $config ): string {
366 // No alias map configured: the field name passes through unchanged.
367 if ( empty( $config['field_corellation'] ) ) {
368 return $field;
369 }
370 // Memoize the decoded map on its raw JSON so repeated calls parse once.
371 static $memo_raw = null;
372 static $memo_map = array();
373
374 $raw = (string) $config['field_corellation'];
375 if ( $raw !== $memo_raw ) {
376 $decoded = json_decode( $raw, true );
377 $memo_raw = $raw;
378 $memo_map = is_array( $decoded ) ? $decoded : array();
379 }
380 $map = $memo_map;
381 // Use the alias only when it's a real non-empty string; else the original.
382 if ( is_array( $map ) && isset( $map[ $field ] ) && is_string( $map[ $field ] ) && '' !== $map[ $field ] ) {
383 return $map[ $field ];
384 }
385 return $field;
386 }
387
388 /**
389 * Does a viewport map request carry visitor search filters? The v1.1 cluster
390 * index is unfiltered by design (active listings, whole MLS), so a filtered
391 * map keeps the zoom-in state — its counts must never be wrong.
392 *
393 * @param array $args Whitelisted filter args (already cleaned of empties).
394 * @return bool True when any key beyond the viewport/paging set is present.
395 */
396 function mlsimport_live_map_has_filters( array $args ): bool {
397 // Viewport + paging keys carry no visitor filter intent.
398 $neutral = array( 'lat_min', 'lat_max', 'lng_min', 'lng_max', 'zoom', 'orderby', 'order', 'limit', 'page' );
399
400 // Any key outside that neutral set means the map is filtered.
401 return array() !== array_diff( array_keys( $args ), $neutral );
402 }
403
404 /**
405 * Quantize a filter set's viewport bbox OUTWARD to three decimals (~110m —
406 * mins floor, maxes ceil, the box only grows) BEFORE the query is built, so
407 * nearby map pans produce the identical query and reuse one cache entry
408 * instead of firing a fresh MLS request per pixel.
409 *
410 * @param array $params Filter params, possibly carrying a bbox.
411 * @return array The params with any bbox edges quantized.
412 */
413 function mlsimport_live_quantize_bbox( array $params ): array {
414 // Mins floor, maxes ceil, so quantizing only ever grows the box.
415 $edges = array(
416 'lat_min' => 'floor',
417 'lat_max' => 'ceil',
418 'lng_min' => 'floor',
419 'lng_max' => 'ceil',
420 );
421 // Snap each present, numeric edge to 3 decimals via *1000/round/÷1000.
422 foreach ( $edges as $edge => $fn ) {
423 if ( isset( $params[ $edge ] ) && is_numeric( $params[ $edge ] ) ) {
424 $params[ $edge ] = number_format( $fn( (float) $params[ $edge ] * 1000 ) / 1000, 3, '.', '' );
425 }
426 }
427 return $params;
428 }
429
430 /**
431 * The /clusters request params for a viewport. The bbox is quantized OUTWARD
432 * to two decimals (~1km — mins floor, maxes ceil), so the box only grows,
433 * edge listings are never dropped, and nearby pans share one cached answer.
434 *
435 * @param array $args Filter args carrying the viewport bbox.
436 * @param int $zoom Map zoom level.
437 * @param int $mls_id The site's MLS id.
438 * @return array|null Ordered param array, or null without a full viewport.
439 */
440 function mlsimport_live_clusters_params( array $args, int $zoom, int $mls_id ) {
441 // A partial viewport (any edge missing/non-numeric) can't be asked of the index.
442 foreach ( array( 'lat_min', 'lat_max', 'lng_min', 'lng_max' ) as $edge ) {
443 if ( ! isset( $args[ $edge ] ) || ! is_numeric( $args[ $edge ] ) ) {
444 return null;
445 }
446 }
447
448 // mls_id + the bbox quantized OUTWARD to 2dp (~1km) + zoom.
449 return array(
450 'mls_id' => $mls_id,
451 'lat_min' => number_format( floor( (float) $args['lat_min'] * 100 ) / 100, 2, '.', '' ),
452 'lat_max' => number_format( ceil( (float) $args['lat_max'] * 100 ) / 100, 2, '.', '' ),
453 'lng_min' => number_format( floor( (float) $args['lng_min'] * 100 ) / 100, 2, '.', '' ),
454 'lng_max' => number_format( ceil( (float) $args['lng_max'] * 100 ) / 100, 2, '.', '' ),
455 'zoom' => $zoom,
456 );
457 }
458
459 /**
460 * One list-valued $filter segment: (Field eq 'A' or Field eq 'B') and .
461 *
462 * @param string $field RESO/OData field name.
463 * @param array $values Accepted values (any match).
464 * @return string Segment ending in ' and ', or '' when no usable values.
465 */
466 function mlsimport_live_filter_list_segment( string $field, array $values ): string {
467 // Trim to non-empty string values.
468 $clean = array();
469 foreach ( $values as $value ) {
470 $value = trim( (string) $value );
471 if ( '' !== $value ) {
472 $clean[] = $value;
473 }
474 }
475 // No usable values: no segment.
476 if ( array() === $clean ) {
477 return '';
478 }
479
480 // Canonical order: an any-match list means the same query in any order,
481 // and one spelling means one cache entry.
482 sort( $clean );
483
484 // One `Field eq 'value'` clause each (single quotes doubled to escape).
485 $clauses = array();
486 foreach ( $clean as $value ) {
487 $clauses[] = $field . " eq '" . str_replace( "'", "''", $value ) . "'";
488 }
489 // OR the clauses together and terminate with ' and ' for concatenation.
490 return '(' . implode( ' or ', $clauses ) . ') and ';
491 }
492