| 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 |
|