PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
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
← All changes | includes/ThemeImport.php +676 -874 5.7.37.2.1 View file →
@@ -1,810 +1,578 @@
1 1 <?php
2 +/**
3 + * ThemeImport — SaaS API client and Stored Listing Write compatibility edge.
4 + *
5 + * The class retains the SaaS request helpers, batch compatibility entry points,
6 + * and reconciliation utilities used by older callers. Per-listing Stored mode
7 + * persistence is deliberately narrow: mlsimportSaasPrepareToImportPerItem()
8 + * translates legacy task option names and delegates once to the injected
9 + * Mlsimport_Stored_Listing_Write module.
10 + *
11 + * Listing status, post/meta/taxonomy writes, field normalization, title, media,
12 + * activity, and error outcomes no longer live in this compatibility class.
13 + *
14 + * @package MLSImport
15 + */
2 16 if ( ! defined( 'ABSPATH' ) ) {
3 17 exit; // Exit if accessed directly
4 18 }
5 19
6 20 /**
7 - * Description of ThemeImport
8 - *
9 - * @author cretu
21 + * Expose legacy API/batch methods around the explicit listing-write module.
10 22 */
11 23 class ThemeImport {
12 24
13 - // put your code here
14 25
26 + // Active theme adapter / identifier (set by callers).
15 27 public $theme;
28 + // Plugin slug/name carried for logging and context.
16 29 public $plugin_name;
30 + // Environment adapter instance (theme-specific meta mapping).
17 31 public $enviroment;
32 + // Cached encoded credential/config values.
18 33 public $encoded_values;
34 +
35 + /** @var object|null Injected Stored Listing Write module. */
36 + private $stored_listing_write;
37 +
19 38 /**
20 - * class construct
39 + * Configure the API client and optional Stored mode write boundary.
21 40 *
22 - * @since 1.0.0
23 - * @access protected
24 - * @var string $plugin_name
41 + * Most ThemeImport instances only call the SaaS API and therefore need no
42 + * writer. Admin composition injects the writer once; listing calls then
43 + * delegate without reading the global admin object or theme adapter.
44 + *
45 + * @param string $plugin_name Plugin slug used by legacy callers.
46 + * @param object|null $stored_listing_write Single listing write module.
25 47 */
48 + public function __construct( $plugin_name = '', $stored_listing_write = null ) {
49 + $this->plugin_name = (string) $plugin_name;
50 + $this->stored_listing_write = $stored_listing_write;
51 + }
52 +
26 53
54 + /**
55 + * Api Request to MLSimport API using CURL
56 + *
57 + * @param string $method The API method to call.
58 + * @param array $values_array The values to pass to the API.
59 + * @param string $type The request type (default is 'GET').
60 + * @return mixed The API response or error message.
61 + */
27 62
28 - /*
29 - *
30 - *
31 - * Api Request to MLSimport APi
32 - *
33 - *
34 - *
35 - * */
63 + public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') {
36 64
37 - public function global_api_request_CURL_saas( $method, $values_array, $type = 'GET' ) {
65 +
38 66 global $mlsimport;
39 - $url = MLSIMPORT_API_URL . $method;
40 - $headers = array( 'Content-Type' => 'text/plain' );
41 -
42 - if ( 'token' !== $method ) {
43 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
44 - $headers = array();
45 - $headers['Content-Type'] = 'application/json';
46 - $headers['authorizationToken'] = $token;
67 +
68 + // Skip validation for token requests
69 + // (the token call is what mints the credential, so it can't require one).
70 + if ($method !== 'token') {
71 + // Ensure a live JWT before any non-token call; bail out with a message on failure.
72 + if (!self::validateAndRefreshToken()) {
73 + return 'Token validation failed';
74 + }
75 + }
47 76
77 + // Build the full endpoint URL from the SaaS base + method path.
78 + $url = MLSIMPORT_API_URL . $method;
79 + // Default headers for the token request (plain text body).
80 + $headers = ['Content-Type' => 'text/plain'];
81 +
82 + // For authenticated calls, swap to JSON + Bearer token headers.
83 + if ($method !== 'token') {
84 + $token = self::getApiToken();
85 + $headers = [
86 + 'Content-Type' => 'application/json',
87 + 'Authorization' => 'Bearer '.$token,
88 + ];
48 89 }
49 90
50 -
51 - $args = array(
52 - 'method' => 'GET',
53 - 'headers' => $headers,
54 - 'body' => wp_json_encode($values_array),
55 - 'timeout' => 120,
91 + // Assemble the wp_remote_* argument array (long timeout for large payloads).
92 + $args = [
93 + 'method' => $type,
94 + 'headers' => $headers,
95 + 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
96 + 'timeout' => 120,
56 97 'redirection' => 10,
57 98 'httpversion' => '1.1',
58 - 'blocking' => true,
59 - 'user-agent' => $_SERVER['HTTP_USER_AGENT']
60 - );
61 -
99 + 'blocking' => true,
100 + 'user-agent' => $_SERVER['HTTP_USER_AGENT'],
101 + ];
62 102
63 - if(empty($values_array)){
64 - unset($args['body']);
65 - }
66 103
104 + // Dispatch as GET or POST depending on $type.
105 + $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
67 106
107 + // #208 recovery: same rule as globalApiRequestSaas() — one refresh and
108 + // one retry when the server rejects the Bearer token mid-flight.
109 + if ( 'token' !== $method
110 + && ! is_wp_error( $response )
111 + && 401 === intval( $response['response']['code'] ?? 0 )
112 + && self::refreshToken() ) {
113 + $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken();
114 + $response = $type === 'GET' ? wp_remote_get( $url, $args ) : wp_remote_post( $url, $args );
115 + }
68 116
69 - // Choose the appropriate WordPress HTTP API function based on the request type
70 - if ( 'GET' === $type ) {
71 - $response = wp_remote_get( $url, $args );
117 + // Transport-level failure: return the WP_Error message string.
118 + if (is_wp_error($response)) {
119 + return $response->get_error_message();
72 120 } else {
73 - $args['method'] = $type;
74 - $response = wp_remote_post( $url, $args );
75 - }
76 -
121 + // Otherwise decode the JSON body and return the array (or a decode-error string).
122 + $body = wp_remote_retrieve_body($response);
77 123
78 -
79 - // Handle response
80 - if ( is_wp_error( $response ) ) {
81 - return $response->get_error_message();
82 - } else {
83 - $body = wp_remote_retrieve_body( $response );
84 - $to_return = json_decode( $body, true );
85 -
86 - return $to_return;
124 + $toReturn = json_decode($body, true);
125 + if (json_last_error() !== JSON_ERROR_NONE) {
126 + return 'JSON decode error: ' . json_last_error_msg();
127 + }
128 + return $toReturn;
87 129 }
88 130 }
89 131
90 132
91 -
92 -
93 -
94 - /*
133 + /**
134 + * Retrieve the API token
95 135 *
96 - *
97 - * Api Request to MLSimport APi
98 - *
99 - *
100 - *
101 - * */
102 -
103 -
104 - public static function global_api_request_saas( $method, $values_array, $type = 'GET' ) {
105 - global $mlsimport;
106 - $url = MLSIMPORT_API_URL . $method;
107 -
108 - $headers = array();
109 - if ( 'token' !== $method && 'mls' !== $method ) {
110 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
111 - $headers = array(
112 - 'authorizationToken' => $token,
113 - 'Content-Type' => 'application/json',
114 - );
115 - }
116 -
117 -
118 - $arguments = array(
119 - 'method' => $type,
120 - 'timeout' => 45,
121 - 'redirection' => 5,
122 - 'httpversion' => '1.0',
123 - 'blocking' => true,
124 - 'headers' => $headers,
125 - 'cookies' => array(),
126 - );
127 -
128 - if ( is_array( $values_array ) && ! empty( $values_array ) ) {
129 - $arguments['body'] = wp_json_encode( $values_array );
130 - }
131 -
132 - $response = wp_remote_post( $url, $arguments );
133 -
134 -
135 - if ( is_wp_error( $response ) ) {
136 - // It is a WordPress error.
137 - $error_code = $response->get_error_code();
138 - $error_message = esc_html( $response->get_error_message( $error_code ) );
139 - $warning_message = sprintf(
140 - esc_html__( 'You have a WordPress Error. Error code: %s. Error Description: %s', 'mlsimport' ),
141 - esc_html( $error_code ),
142 - $error_message
143 - );
144 - print esc_html( '<div class="mlsimport_warning">' . $warning_message . '</div>' );
145 -
146 - $received_data['succes'] = false;
147 - return $received_data;
148 - }
149 -
150 - if ( isset( $response['response']['code'] ) && 200 === $response['response']['code'] ) {
151 - $received_data = json_decode( wp_remote_retrieve_body( $response ), true );
152 -
153 - return $received_data;
154 - } else {
155 - $received_data['succes'] = false;
156 - return $received_data;
157 - }
158 - exit();
136 + * @return string The API token.
137 + */
138 + private static function getApiToken() {
139 + global $mlsimport;
140 + return $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
159 141 }
160 142
161 143
162 -
163 -
164 -
165 -
166 -
167 -
168 144 /**
145 + * Api Request to MLSimport API
169 146 *
170 - *
171 - *
172 - * Parse Result Array
147 + * @param string $method The API method to call.
148 + * @param array $valuesArray The values to pass to the API.
149 + * @param string $type The request type (default is 'GET').
150 + * @return array The API response data.
173 151 */
174 - public function mlsimport_saas_parse_search_array_per_item( $ready_to_parse_array, $item_id_array, $batch_key, $mlsimport_item_option_data ) {
175 - $logs = '';
176 152
177 - wp_cache_flush();
178 - gc_collect_cycles();
179 - $counter_prop = 0;
180 - foreach ( $ready_to_parse_array['data'] as $key => $property ) {
181 - ++$counter_prop;
182 -
183 - $logs = $this->mlsimport_mem_usage() . '=== In parse search array, listing no ' . $key . ' from batch ' . $batch_key . ' with Listingkey: ' . $property['ListingKey'] . PHP_EOL;
184 - mlsimport_saas_single_write_import_custom_logs( $logs, 'import' );
185 -
186 -
187 - wp_cache_delete( 'mlsimport_force_stop_' . $item_id_array['item_id'], 'options' );
188 -
189 - $status = get_option( 'mlsimport_force_stop_' . $item_id_array['item_id'] );
190 - $logs = $this->mlsimport_mem_usage() . ' / on Batch ' . $item_id_array['batch_counter'] . ', Item ID: ' . $item_id_array['item_id'] . '/' . $counter_prop . ' check ListingKey ' . $property['ListingKey'] . ' - stop command issued ? ' . $status . PHP_EOL;
191 - mlsimport_saas_single_write_import_custom_logs( $logs, 'import' );
192 -
193 - if ( 'no' === $status ) {
194 - $logs = 'Will proceed to import - Memory Used ' . $this->mlsimport_mem_usage() . PHP_EOL;
195 - mlsimport_saas_single_write_import_custom_logs( $logs, 'import' );
196 - $this->mlsimport_saas_prepare_to_import_per_item( $property, $item_id_array, 'normal', $mlsimport_item_option_data );
197 - } else {
198 - update_post_meta( $item_id_array['item_id'], 'mlsimport_spawn_status', 'completed' );
199 - }
200 - unset( $logs );
201 - }
202 -
203 - unset( $ready_to_parse_array );
204 - unset( $logs );
205 - }
206 -
207 -
208 153 /**
154 + * Fire-and-forget POST to the SaaS API. Refreshes the JWT token (blocking — a
155 + * required separate request); returns false without sending if the token is
156 + * unavailable. Otherwise issues wp_remote_post() with blocking=false, timeout=0.01
157 + * and returns true. The response is never inspected.
209 158 *
210 - *
211 - * Parse Result Array in CROn
159 + * @param string $method The API method/path to call.
160 + * @param array $valuesArray The request body data.
161 + * @return bool True if dispatched, false if token unavailable.
212 162 */
213 - public function mlsimport_saas_cron_parse_search_array_per_item( $ready_to_parse_array, $item_id_array, $batch_key ) {
214 -
215 - $mlsimport_item_option_data = array();
216 - $mlsimport_item_option_data['mlsimport_item_standardstatus'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_standardstatus', true );
217 - $mlsimport_item_option_data['mlsimport_item_property_user'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_property_user', true );
218 - $mlsimport_item_option_data['mlsimport_item_agent'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_agent', true );
219 - $mlsimport_item_option_data['mlsimport_item_property_status'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_property_status', true );
220 -
221 - foreach ( $ready_to_parse_array['data'] as $key => $property ) {
222 - $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batch_key . ' with Listingkey: ' . $property['ListingKey'] . PHP_EOL;
223 - mlsimport_saas_single_write_import_custom_logs( $logs, 'cron' );
224 - $this->mlsimport_saas_prepare_to_import_per_item( $property, $item_id_array, 'cron', $mlsimport_item_option_data );
163 + public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool {
164 + if ( ! self::validateAndRefreshToken() ) {
165 + return false;
225 166 }
226 - }
227 167
168 + $token = self::getApiToken();
228 169
170 + wp_remote_post(
171 + MLSIMPORT_API_URL . $method,
172 + [
173 + 'method' => 'POST',
174 + 'timeout' => 0.01,
175 + 'blocking' => false,
176 + 'headers' => [
177 + 'Authorization' => 'Bearer ' . $token,
178 + 'Content-Type' => 'application/json',
179 + ],
180 + 'body' => wp_json_encode( $valuesArray ),
181 + ]
182 + );
229 183
184 + return true;
185 + }
230 186
231 187
232 -
233 -
234 188 /**
189 + * Blocking request to the SaaS API returning the decoded response.
235 190 *
191 + * Validates/refreshes the JWT for anything other than the public 'token'
192 + * and 'mls' methods, always POSTs the JSON body (regardless of $type),
193 + * and normalises errors into a ['success' => false, ...] array. On HTTP 200
194 + * the raw decoded body is returned as-is.
236 195 *
237 - * check if property already imported
196 + * @param string $method The API method/path to call.
197 + * @param array $valuesArray The request body data.
198 + * @param string $type The nominal request type (default 'GET').
199 + * @return mixed Decoded response array, or an error descriptor array.
238 200 */
239 - public function mlsimport_saas_retrive_property_by_id( $key, $post_type = 'estate_property' ) {
201 + public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') {
202 + global $mlsimport;
203 + // Skip validation for token and mls requests
204 + if ($method !== 'token' && $method !== 'mls') {
205 + // Guarantee a valid token; otherwise return a failure descriptor.
206 + if (!self::validateAndRefreshToken()) {
207 + return [
208 + 'success' => false,
209 + 'error_message' => 'Token validation failed'
210 + ];
211 + }
212 + }
240 213
241 - $args = array(
242 - 'post_type' => $post_type,
243 - 'post_status' => 'any',
244 214
245 - 'meta_query' => array(
246 - array(
247 - 'key' => 'ListingKey',
248 - 'value' => $key,
249 - 'compare' => '=',
250 - ),
251 - ),
252 - 'fields' => 'ids',
253 - );
215 + // Full endpoint URL.
216 + $url = MLSIMPORT_API_URL . $method;
254 217
255 - $prop_selection = new WP_Query( $args );
256 - if ( $prop_selection->have_posts() ) {
257 - while ( $prop_selection->have_posts() ) {
258 - $prop_selection->the_post();
259 - $the_id = get_the_ID();
260 - wp_reset_postdata();
261 - return $the_id;
218 + // Attach Bearer auth headers for authenticated methods only.
219 + $headers = [];
220 + if ($method !== 'token' && $method !== 'mls') {
221 + $token = self::getApiToken();
222 + $headers = [
223 + 'Authorization' => 'Bearer '.$token,
224 + 'Content-Type' => 'application/json',
225 + ];
262 226 }
263 - } else {
264 - wp_reset_postdata();
265 - return 0;
266 - }
267 - }
268 227
269 228
229 + // Request arguments (note: always dispatched via wp_remote_post below).
230 + $args = [
231 + 'method' => $type,
232 + 'timeout' => 45,
233 + 'redirection' => 5,
234 + 'httpversion' => '1.0',
235 + 'blocking' => true,
236 + 'headers' => $headers,
237 + 'cookies' => [],
238 + 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
239 + ];
240 + // Always POST (even for logical GETs) — the SaaS expects a JSON body.
241 + $response = wp_remote_post($url, $args);
270 242
243 + // #208 recovery: a 401 on an authenticated call means the server
244 + // rejected the Bearer token even though the stored expiry looked
245 + // valid (revoked server-side, clock skew). Refresh once and retry
246 + // the same request once; a second 401 falls through to the normal
247 + // error path below. Token/mls calls carry no Bearer, so no retry.
248 + if ( 'token' !== $method && 'mls' !== $method
249 + && ! is_wp_error( $response )
250 + && 401 === intval( $response['response']['code'] ?? 0 )
251 + && self::refreshToken() ) {
252 + $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken();
253 + $response = wp_remote_post( $url, $args );
254 + }
271 255
256 + // Transport error → structured failure with WP error code/message.
257 + if (is_wp_error($response)) {
258 + return [
259 + 'success' => false,
260 + 'error_code' => $response->get_error_code(),
261 + 'error_message' => esc_html($response->get_error_message())
262 + ];
263 + }
272 264
273 - /**
274 - * clear taxonomy
275 - *
276 - * @since 1.0.0
277 - * @access protected
278 - * @var string $plugin_name
279 - */
280 - public function mlsimport_saas_clear_property_for_taxonomy( $property_id, $taxonomies ) {
265 + // Extract HTTP status code and raw body.
266 + $status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0;
267 + $body = wp_remote_retrieve_body($response);
281 268
282 - if ( is_array( $taxonomies ) ) :
283 - foreach ( $taxonomies as $taxonomy => $term ) :
284 - if (is_wp_error($taxonomy)) {
285 - error_log('Error with taxonomy: ' . $taxonomy->get_error_message());
286 - continue; // Skip this iteration
287 - }
288 -
289 - if ( taxonomy_exists($taxonomy) ) {
290 - wp_delete_object_term_relationships($property_id, $taxonomy);
291 - } else {
292 - error_log("Taxonomy does not exist: {$taxonomy}");
293 - }
294 - endforeach;
295 - endif;
296 - }
269 + // 200 → return the decoded payload untouched.
270 + if (200 === $status_code) {
271 + $receivedData = json_decode($body, true);
272 + return $receivedData;
273 + }
297 274
275 + // Non-200: try to pull a human-readable error out of the JSON body.
276 + $error_message = 'Unknown error';
277 + $error_code = $status_code;
298 278
279 + $decoded_body = json_decode($body, true);
280 + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) {
281 + // Preferred shape: { error: { message, code } }.
282 + if (isset($decoded_body['error']['message'])) {
283 + $error_message = $decoded_body['error']['message'];
284 + if (isset($decoded_body['error']['code'])) {
285 + $error_code = $decoded_body['error']['code'];
286 + }
287 + // Fallback shape: { message }.
288 + } elseif (isset($decoded_body['message'])) {
289 + $error_message = $decoded_body['message'];
290 + }
291 + }
299 292
293 + // Return the normalised error descriptor (the exit() below is unreachable).
294 + return [
295 + 'success' => false,
296 + 'error_code' => $error_code,
297 + 'error_message' => esc_html($error_message),
298 + ];
300 299
300 + exit();
301 + }
301 302
302 303
303 -
304 -
304 +
305 305 /**
306 - * return non encoded encoded values
306 + * Check if token is expired and refresh if needed
307 + * Call this before any external API request
307 308 *
308 - * @since 1.0.0
309 - * @access protected
310 - * @var string $plugin_name
309 + * @return bool True if token is valid, false if refresh failed
311 310 */
312 - public function mlsimport_saas_return_non_encoded_value( $item, $encoded_values ) {
311 + private static function validateAndRefreshToken() {
312 + global $mlsimport;
313 +
314 + // Get stored expiry timestamp
315 + $token_expiry = get_option('mlsimport_token_expiry', 0);
316 + $current_time = time();
317 +
318 + // Check if token is expired (now at/after the stored expiry).
319 + if ($current_time >= $token_expiry) {
320 + // Token expired, refresh it
321 + $refresh_result = self::refreshToken();
313 322
314 - if ( is_array( $item ) ) {
315 - if ( ! empty( $encoded_values ) ) {
316 - foreach ( $item as $key => $value ) {
317 - if ( isset( $encoded_values [ $value ] ) ) {
318 - $item[ $key ] = $encoded_values [ $value ];
319 - }
320 - }
323 + // Propagate refresh failure to the caller.
324 + if (!$refresh_result) {
325 + return false;
321 326 }
322 - return $item;
323 - } elseif ( ! empty( $encoded_values ) && isset( $encoded_values [ $item ] ) ) {
324 - return $encoded_values [ $item ];
325 - } else {
326 - return $item;
327 327 }
328 +
329 + // Token is present and not past expiry.
330 + return true;
328 331 }
329 332
330 333 /**
331 - * set taxonomy
334 + * Record the SaaS connection-health state (#208).
332 335 *
333 - * @since 1.0.0
334 - * @access protected
335 - * @var string $plugin_name
336 + * Stores array{status, since} in the mlsimport_connection_health option:
337 + * 'healthy', 'credentials_invalid' (server rejected the stored account),
338 + * 'no_subscription' (password accepted, account not active — #322) or
339 + * 'credentials_missing' (nothing configured). Transient failures such
340 + * as network timeouts never call this, so a working state is not lost to
341 + * a hiccup. Re-recording an unchanged status is skipped so 'since' keeps
342 + * pointing at when the state actually began.
343 + *
344 + * @param string $status New health status keyword.
345 + * @return void
336 346 */
337 - public function mlsimport_saas_update_taxonomy_for_property( $taxonomy, $property_id, $field_values ) {
338 -
339 - if ( ! is_array( $field_values ) ) {
340 - if ( strpos( $field_values, ',' ) !== false ) {
341 - $field_values = explode( ',', $field_values );
342 - } else {
343 - $field_values = array( $field_values );
344 - }
347 + private static function setConnectionHealth( $status ) {
348 + $health = get_option( 'mlsimport_connection_health', array() );
349 + if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) {
350 + return;
345 351 }
352 + update_option(
353 + 'mlsimport_connection_health',
354 + array(
355 + 'status' => $status,
356 + 'since' => time(),
357 + )
358 + );
346 359
347 - // Remove empty values and decode values in one pass
348 - $processed_field_values = array();
349 - foreach ( $field_values as $value ) {
350 - if ( ! empty( $value ) ) {
351 - $processed_field_values[] = $value;
360 + // #208: a state CHANGE is the incident boundary — broken credentials
361 + // open the connection incident, a working refresh resolves it. The
362 + // alerts module dedups, so this cannot spam the SaaS.
363 + if ( 'healthy' === $status ) {
364 + if ( function_exists( 'mlsimport_alert_resolve' ) ) {
365 + mlsimport_alert_resolve( 'connection:credentials' );
352 366 }
367 + } elseif ( function_exists( 'mlsimport_alert_open' ) ) {
368 + mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) );
353 369 }
354 -
355 - // Bulk update terms if array is not empty
356 - if ( ! empty( $processed_field_values ) ) {
357 - wp_set_object_terms( $property_id, $processed_field_values, $taxonomy, true );
358 - clean_term_cache( $property_id, $taxonomy );
359 - }
360 370 }
361 371
362 -
363 -
364 -
365 372 /**
366 - * Set Property Title
373 + * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
367 374 *
368 - * @since 1.0.0
369 - * @access protected
370 - * @var string $plugin_name
375 + * Reads the stored username/password, POSTs them, and on success stores the
376 + * token in a transient plus the expiry timestamp in an option. Bumps the
377 + * 'token_failures' telemetry counter on every failure path — WITHOUT a
378 + * connection id (#283): the SaaS JWT is account-level, shared by every
379 + * connection, so its failures belong to no single MLS and count only in
380 + * the global bucket.
381 + *
382 + * @return bool True on successful refresh, false otherwise.
371 383 */
372 - public function mlsimport_saas_update_property_title( $property_id, $mls_import_post_id, $property ) {
373 -
384 + private static function refreshToken() {
374 385 global $mlsimport;
386 +
387 + // Get credentials for token request
388 + $options = get_option('mlsimport_admin_options');
389 + // Pull the SaaS account credentials out of the plugin options.
390 + $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : '';
391 + $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : '';
375 392
376 - $title_format = esc_html( get_post_meta( $mls_import_post_id, 'mlsimport_item_title_format', true ) );
377 -
378 - if ( '' === $title_format ) {
379 - $options = get_option( 'mlsimport_admin_mls_sync' );
380 - $title_format = $options['title_format'];
393 + // No credentials configured → cannot refresh.
394 + if (empty($username) || empty($password)) {
395 + mlsimport_telemetry_bump( 'token_failures' );
396 + self::setConnectionHealth( 'credentials_missing' );
397 + return false;
381 398 }
382 399
383 - $start = '{';
384 - $end = '}';
385 - $title_array = $this->str_between_all( $title_format, $start, $end );
386 -
387 - $property_extra_meta_array_lowecase = array_change_key_case( $property['extra_meta'], CASE_LOWER );
388 -
389 - foreach ( $title_array as $key => $value ) {
390 - $replace = '';
391 - if ( 'Address' === $value ) {
392 - if ( isset( $property['adr_title'] ) ) {
393 - $replace = $property['adr_title'];
394 - }
395 - $title_format = str_replace( '{Address}', $replace, $title_format );
396 - } elseif ( 'City' === $value ) {
397 - if ( isset( $property['adr_city'] ) ) {
398 - $replace = $property['adr_city'];
399 - }
400 - $title_format = str_replace( '{City}', $replace, $title_format );
401 - } elseif ( 'CountyOrParish' === $value ) {
402 - if ( isset( $property['adr_county'] ) ) {
403 - $replace = $property['adr_county'];
404 - }
405 - $title_format = str_replace( '{CountyOrParish}', $replace, $title_format );
406 - } elseif ( 'PropertyType' === $value ) {
407 - if ( isset( $property['adr_type'] ) ) {
408 - $replace = $property['adr_type'];
409 - }
410 - $title_format = str_replace( '{PropertyType}', $replace, $title_format );
411 - } elseif ( 'Bedrooms' === $value ) {
412 - if ( isset( $property['adr_bedrooms'] ) ) {
413 - $replace = $property['adr_bedrooms'];
414 - }
415 - $title_format = str_replace( '{Bedrooms}', $replace, $title_format );
416 - } elseif ( 'Bathrooms' === $value ) {
417 - if ( isset( $property['adr_bathrooms'] ) ) {
418 - $replace = $property['adr_bathrooms'];
419 - }
420 - $title_format = str_replace( '{Bathrooms}', $replace, $title_format );
421 - } elseif ( 'ListingKey' === $value ) {
422 - $replace = $property['ListingKey'];
423 - if ( '' !== $replace ) {
424 - $title_format = str_replace( '{ListingKey}', $replace, $title_format );
425 - }
426 - } elseif ( 'ListingId' === $value ) {
427 - if ( isset( $property['adr_listingid'] ) ) {
428 - $replace = $property['adr_listingid'];
429 - }
430 - if ( '' !== $replace ) {
431 - $title_format = str_replace( '{ListingId}', $replace, $title_format );
432 - }
433 - } elseif ( 'StateOrProvince' === $value ) {
434 - if ( isset( $property['extra_meta']['StateOrProvince'] ) ) {
435 - $replace = $property['extra_meta']['StateOrProvince'];
436 - }
437 - $title_format = str_replace( '{StateOrProvince}', $replace, $title_format );
438 - } elseif ( 'PostalCode' === $value ) {
439 - if ( isset( $property['meta']['property_zip'] ) ) {
440 - if ( is_array( $property['meta']['property_zip'] ) ) {
441 - $replace = strval( $property['meta']['property_zip'][0] );
442 - } else {
443 - $replace = strval( $property['meta']['property_zip'] );
444 - }
445 - } elseif ( isset( $property['meta']['fave_property_zip'] ) ) {
446 - if ( is_array( $property['meta']['fave_property_zip'] ) ) {
447 - $replace = strval( $property['meta']['fave_property_zip'][0] );
448 - } else {
449 - $replace = strval( $property['meta']['fave_property_zip'] );
450 - }
451 - }
452 -
453 - $title_format = str_replace( '{PostalCode}', $replace, $title_format );
454 - } elseif ( 'StreetNumberNumeric' === $value ) {
455 - if ( isset( $property_extra_meta_array_lowecase['streetnumbernumeric'] ) ) {
456 - $replace = $property_extra_meta_array_lowecase['streetnumbernumeric'];
457 - }
458 - $title_format = str_replace( '{StreetNumberNumeric}', $replace, $title_format );
459 - } elseif ( 'StreetName' === $value ) {
460 - if ( isset( $property_extra_meta_array_lowecase['streetname'] ) ) {
461 - $replace = $property_extra_meta_array_lowecase['streetname'];
462 - }
463 - $title_format = str_replace( '{StreetName}', $replace, $title_format );
400 + // #208 single-flight: only one process may refresh at a time.
401 + // add_option() is a plain INSERT, so a concurrent process loses the
402 + // race and backs off without firing a second token request. A lock
403 + // older than 60 seconds belongs to a crashed owner (the token request
404 + // itself times out at 45) and is taken over instead.
405 + if ( ! add_option( 'mlsimport_token_refresh_lock', time(), '', 'no' ) ) {
406 + $lock_held_since = intval( get_option( 'mlsimport_token_refresh_lock', 0 ) );
407 + if ( time() - $lock_held_since < 60 ) {
408 + return false;
464 409 }
410 + update_option( 'mlsimport_token_refresh_lock', time() );
465 411 }
466 412
467 - $post = array(
468 - 'ID' => $property_id,
469 - 'post_title' => $title_format,
470 - 'post_name' => $title_format,
413 + // Prepare token request
414 + $url = MLSIMPORT_API_URL . 'token';
415 + $body = wp_json_encode(array(
416 + 'username' => $username,
417 + 'password' => $password
418 + ));
419 +
420 + $args = array(
421 + 'method' => 'POST',
422 + 'headers' => array(
423 + 'Content-Type' => 'application/json'
424 + ),
425 + 'body' => $body,
426 + 'timeout' => 45
471 427 );
428 +
429 + // Make token request
430 + $response = wp_remote_post($url, $args);
472 431
473 - wp_update_post( $post );
432 + // Transport failure → count and abort (lock released for the next try).
433 + if (is_wp_error($response)) {
434 + mlsimport_telemetry_bump( 'token_failures' );
435 + delete_option( 'mlsimport_token_refresh_lock' );
436 + return false;
437 + }
474 438
475 - unset( $property_extra_meta_array_lowecase );
476 - return $title_format;
477 - }
439 + // Decode the JSON token response.
440 + $body = wp_remote_retrieve_body($response);
441 + $data = json_decode($body, true);
442 + $code = intval( $response['response']['code'] ?? 0 );
478 443
444 + // Reject any response missing success/token/expires.
445 + if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
446 + mlsimport_telemetry_bump( 'token_failures' );
447 + delete_option( 'mlsimport_token_refresh_lock' );
448 + // The server answered and said no → terminal until the user acts.
449 + // HTTP 403 means the password was right but the account has no
450 + // active subscription (#322); anything else is bad credentials.
451 + // A malformed/partial body is a server hiccup instead and leaves
452 + // health untouched.
453 + if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) {
454 + self::setConnectionHealth( 403 === $code ? 'no_subscription' : 'credentials_invalid' );
455 + // Same verdict, remembered for the "not connected" screens.
456 + mlsimport_account_status_record( array( 'success' => false, 'error_code' => $code ) );
457 + }
458 + return false;
459 + }
479 460
461 + // A working login wipes any remembered failure reason (#322).
462 + mlsimport_account_status_record( $data );
463 +
464 + // Store new token and expiry
465 + //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
480 466
467 + // Cache the token in a transient sized to its remaining lifetime.
468 + $expires_in = $data['expires'] - time();
469 + set_transient('mlsimport_saas_token', $data['token'], $expires_in);
481 470
471 + // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
472 + update_option('mlsimport_token_expiry', intval($data['expires']));
482 473
474 + // First successful SaaS account connection (lifecycle telemetry).
475 + mlsimport_telemetry_set_once( 'account_connected_at', time() );
483 476
477 + // Refresh finished — release the single-flight lock.
478 + delete_option( 'mlsimport_token_refresh_lock' );
484 479
480 + // A minted token proves the account works → back to healthy.
481 + self::setConnectionHealth( 'healthy' );
485 482
486 - /**
487 - *
488 - *
489 - *
490 - *
491 - * import property -prepare
492 - *
493 - * @since 1.0.0
494 - * @access protected
495 - * @var string $plugin_name
496 - */
497 - public function mlsimport_saas_prepare_to_import_per_item( $property, $item_id_array, $tip_import, $mlsimport_item_option_data ) {
498 - // check if MLS id is set
499 - global $mlsimport;
483 + return true;
484 + }
500 485
501 486
502 - $mls_import_item_status = $mlsimport_item_option_data['mlsimport_item_standardstatus'];
503 - $new_author = $mlsimport_item_option_data['mlsimport_item_property_user'];
504 - $new_agent = $mlsimport_item_option_data['mlsimport_item_agent'];
505 - $property_status = $mlsimport_item_option_data['mlsimport_item_property_status'];
506 487
507 - if ( is_array( $mls_import_item_status ) ) {
508 - $mls_import_item_status = array_map( 'strtolower', $mls_import_item_status );
509 - }
510 488
511 -
512 - if ( ! isset( $property['ListingKey'] ) ) {
513 - $log = 'ERROR : No Listing Key ' . PHP_EOL;
514 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
515 - return;
516 - }
517 489
518 - ob_start();
519 490
520 - $ListingKey = $property['ListingKey'];
521 - $listing_post_type = $mlsimport->admin->env_data->get_property_post_type();
522 - $property_id = intval( $this->mlsimport_saas_retrive_property_by_id( $ListingKey, $listing_post_type ) );
523 - $status = 'Not Found';
524 - $property_history = array();
525 - if ( isset( $property['StandardStatus'] ) ) {
526 - $status = strtolower( $property['StandardStatus'] );
527 - } else {
528 - $status = strtolower( $property['extra_meta']['MlsStatus'] );
529 - }
530 491
531 - if ( 0 === intval( $property_id) ) {
532 - $is_insert = 'no';
533 - if (
534 - 'active' === $status || 'active under contract' === $status ||
535 - 'active with contract' === $status || 'activewithcontract' === $status ||
536 - 'status' === $status || 'activeundercontract' === $status ||
537 - 'comingsoon' === $status || 'coming soon' === $status ||
538 - 'pending' === $status
539 - ) {
540 - $is_insert = 'yes';
541 - if ( 'cron' === $tip_import ) {
542 - if ( ! in_array( $status, $mls_import_item_status ) ) {
543 - // REJECTED because not selected';
544 - $is_insert = 'no';
545 - }
546 - }
547 - }
548 - } else {
549 - $is_insert = 'no';
550 - }
551 492
552 - $log = $this->mlsimport_mem_usage() . '==========' . wp_json_encode( $mls_import_item_status ) . '/' . $new_author . '/' . $new_agent . '/' . $property_status . '/ We have property with $ListingKey=' . $ListingKey . ' id=' . $property_id . ' with status ' . $status . ' is insert? ' . $is_insert . PHP_EOL;
493 +
553 494
554 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
555 495
556 - // content set and check
557 - $content = '';
558 - $submit_title = $ListingKey;
559 - if ( isset( $property['content'] ) ) {
560 - $content = $property['content'];
561 - }
496 + /**
497 + * Write logs for import process
498 + *
499 + * @param string $logs The log message to write.
500 + * @param string $type The type of log.
501 + */
502 + private function writeImportLogs($logs, $type) {
503 + mlsimport_saas_single_write_import_custom_logs($logs, $type);
504 + }
562 505
563 - // $new_author = get_post_meta($item_id_array['item_id'],'mlsimport_item_property_user',true );
564 - // $new_agent = esc_html(get_post_meta($item_id_array['item_id'], 'mlsimport_item_agent', true));
565 - // $property_status = esc_html(get_post_meta($item_id_array['item_id'], 'mlsimport_item_property_status', true));
566 506
567 - $mlsimport_item_option_data = null;
568 507
569 - if ( 'yes' === $is_insert ) {
570 - $post = array(
571 - 'post_title' => $submit_title,
572 - 'post_content' => $content,
573 - 'post_status' => $property_status,
574 - 'post_type' => $listing_post_type,
575 - 'post_author' => $new_author,
576 - );
577 508
578 - $property_id = wp_insert_post( $post );
509 +
579 510
580 - if ( is_wp_error( $property_id ) ) {
581 - $log = 'ERROR : on inserting ' . PHP_EOL;
582 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
583 - } else {
584 - update_post_meta( $property_id, 'ListingKey', $ListingKey );
585 - $property_history[] = date( 'F j, Y, g:i a' ) . ': We Inserted the property with Default title : ' . $submit_title . ' and received id:' . $property_id;
586 - }
587 511
588 - clean_post_cache( $property_id );
589 - } elseif ( 0 !== $property_id ) {
590 - $property_history = array();
591 - $property_history[] = get_post_meta( $property_id, 'mlsimport_property_history', true );
592 512
593 - $delete_statuses = array(
594 - 'incomplete' => 'incomplete',
595 - 'hold' => 'hold',
596 - 'canceled' => 'canceled',
597 - 'closed' => 'closed',
598 - 'delete' => 'delete',
599 - 'expired' => 'expired',
600 - 'withdrawn' => 'withdrawn',
601 - );
602 513
603 - if ( ! in_array( 'pending', $mls_import_item_status ) ) {
604 - $delete_statuses['pending'] = 'pending';
605 - }
606 514
607 - if ( in_array( $status, $delete_statuses ) ) {
608 - $log = 'Property with ID ' . $property_id . ' and with name ' . get_the_title( $property_id ) . ' has a status of <strong>' . $status . '</strong> and will be deleted' . PHP_EOL;
609 - $log = '--1---> ' . $log . wp_json_encode( $mls_import_item_status ) . PHP_EOL;
610 - if ( in_array( $status, $mls_import_item_status ) ) {
611 - $log .= '-2--> canceling the delete ' . PHP_EOL;
612 - } else {
613 - $log .= '--3--> proceed with the delete ' . PHP_EOL;
614 - $this->delete_property( $property_id, $ListingKey );
615 - }
616 515
617 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
618 - unset( $log );
619 516
620 - return;
621 - } else {
622 517
623 - $post = array(
624 - 'ID' => $property_id,
625 - 'post_content' => $content,
626 - 'post_type' => $listing_post_type,
627 - 'post_author' => $new_author,
628 - );
629 518
630 - $log = ' Property with ID ' . $property_id . ' and with name ' . get_the_title( $property_id ) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
631 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
632 519
633 - $property_id = wp_update_post( $post );
634 520
635 - if ( is_wp_error( $property_id ) ) {
636 - $log = 'ERROR : on edit ' . PHP_EOL;
637 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
638 - } else {
639 - $submit_title = get_the_title( $property_id );
640 - $property_history[] = gmdate( 'F j, Y, g:i a' ) . ': Property with title: ' . $submit_title . ', id:' . $property_id . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
641 - }
642 521
643 - clean_post_cache( $property_id );
644 - }
645 - }
646 522
647 - //
648 - // Insert or edit POST ends her - START ADDING DETAILS
649 - //
650 523
651 - $encoded_values = array();// may be obsolote
652 524
653 - if ( intval( $property_id ) === 0 ) {
654 - mlsimport_saas_single_write_import_custom_logs( 'ERROR property id is 0' . PHP_EOL, $tip_import );
655 - return; // no point in going forward if no id
656 - }
657 525
658 - // Start working on Taxonomies
659 - //
660 - $tax_log = array();
661 - $tax_log[] = 'Property with ID ' . $property_id . ' NO taxonomies found ! ';
662 526
663 - if ( isset( $property['taxonomies'] ) && is_array( $property['taxonomies'] ) ) {
664 - $taxonomies = $property['taxonomies'];
665 - $tax_log = array();
666 527
667 - $this->mlsimport_saas_clear_property_for_taxonomy( $property_id, $property['taxonomies'] );
668 528
669 - foreach ( $taxonomies as $taxonomy => $term ) :
670 - $this->mlsimport_saas_update_taxonomy_for_property( $taxonomy, $property_id, $term );
671 - $property_history[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode( $term );
672 - $tax_log [] = 'Memory:' . $this->mlsimport_mem_usage() . ' Property with ID ' . $property_id . ' Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode( $term );
673 - endforeach;
674 - $taxonomies = null;
675 - }
676 529
677 - $tax_log = implode( PHP_EOL, $tax_log );
678 - mlsimport_saas_single_write_import_custom_logs( $tax_log, $tip_import );
679 - $tax_log = null;
680 530
681 - // Pre Meta jobs
682 - //
683 - $property = $this->mlsimport_saas_prepare_meta_for_property( $property );
684 531
685 - // Start working on Meta
686 - //
687 532
688 - $meta_log = array();
689 - $meta_log [] = 'Property with ID ' . $property_id . ' NO meta found ! ' . PHP_EOL;
690 533
691 - if ( isset( $property['meta'] ) && is_array( $property['meta'] ) ) {
692 - $meta_properties = $property['meta'];
693 - foreach ( $meta_properties as $meta_name => $meta_value ) :
694 - if ( is_array( $meta_value ) ) {
695 - $meta_value = implode( ',', $meta_value );
696 - }
697 - update_post_meta( $property_id, $meta_name, $meta_value );
698 534
699 - $property_history[] = 'Updated Meta ' . $meta_name . ' with meta_value ' . $meta_value;
700 - $meta_log [] = 'Memory:' . $this->mlsimport_mem_usage() . 'Property with ID ' . $property_id . ' Updated Meta ' . $meta_name . ' with value ' . $meta_value;
701 - endforeach;
702 - }
703 - $meta_properties = null;
704 - $meta_log = implode( PHP_EOL, $meta_log );
705 - mlsimport_saas_single_write_import_custom_logs( $meta_log, $tip_import );
706 - $meta_log = null;
707 535
708 - // Start working on EXTRA Meta
709 - //
710 536
711 - $extra_meta_log = 'Property with ID ' . $property_id . ' Start Extra meta ! ' . PHP_EOL;
712 - $extra_meta_result = $mlsimport->admin->env_data->mlsimport_saas_set_extra_meta( $property_id, $property );
537 +
713 538
714 - if ( isset( $extra_meta_result['property_history'] ) ) {
715 - $property_history = array_merge( $property_history, (array) $extra_meta_result['property_history'] );
716 - }
717 - if ( isset( $extra_meta_result['extra_meta_log'] ) ) {
718 - $extra_meta_log .= $extra_meta_result['extra_meta_log'];
719 - }
720 539
721 - mlsimport_saas_single_write_import_custom_logs( $extra_meta_log, $tip_import );
722 540
723 - $extra_meta_log = null;
724 - $extra_meta_result = null;
725 541
726 - // Start working on Property Media
727 - //
728 542
729 - $media = $property['Media'];
730 - $media_history = $this->mlsimport_sass_attach_media_to_post( $property_id, $media, $is_insert );
731 - $property_history = array_merge( $property_history, (array) $media_history );
732 - $media = null;
733 - $media_history = null;
734 543
735 - // Updateing property title and ending
736 - //
737 544
738 - $new_title = $this->mlsimport_saas_update_property_title( $property_id, $item_id_array['item_id'], $property );
739 - $property_history[] = 'Updated title to ' . $new_title . '</br>';
740 545
741 - // extra fields to be checked
742 - $global_extra_fields = array();
743 - $mlsimport->admin->env_data->correlation_update_after( $is_insert, $property_id, $global_extra_fields, $new_agent );
546 +
744 547
745 - // saving history
746 - if ( ! empty( $property_history ) ) {
747 - $disable_history = intval( get_option( 'mlsimport-disable-history', 1 ) );
748 - if ( 1 === intval( $disable_history ) ) {
749 - $property_history[] = '---------------------------------------------------------------</br>';
750 - $property_history = implode( '</br>', $property_history );
751 - update_post_meta( $property_id, 'mlsimport_property_history', $property_history );
752 - }
753 - }
754 548
755 - $logs = PHP_EOL . 'Ending on Property ' . $property_id . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $is_insert . ' with new title: ' . $new_title . ' ' . PHP_EOL;
756 - mlsimport_saas_single_write_import_custom_logs( $logs, $tip_import );
757 549
758 - $capture = ob_get_contents();
759 - ob_end_clean();
760 - mlsimport_saas_single_write_import_custom_logs( $capture, $tip_import );
761 550
762 - $post = null;
763 - $capture = null;
764 - $property_status = null;
765 - $new_agent = null;
766 - $new_author = null;
767 - $property_history = null;
768 - $tax_log = null;
769 - $meta_log = null;
770 - $extra_meta_log = null;
771 - $media_history = null;
772 - $logs = null;
773 - $capture = null;
774 - clean_post_cache( $property_id );
775 - wp_cache_flush();
776 - gc_collect_cycles();
777 - }
778 551
779 552
780 553
781 554
782 555
783 -
784 -
785 - public function mlsimport_mem_usage() {
786 - $mem_usage = memory_get_usage( true );
787 - $mem_usage_show = round( $mem_usage / 1048576, 2 );
788 - return $mem_usage_show . 'mb ';
789 - }
790 -
791 -
792 556 /**
793 - * prepare meta data
557 + * Return user option
794 558 *
795 - * @since 1.0.0
796 - * @access protected
797 - * @var string $plugin_name
559 + * @param int $selected The selected user ID.
560 + * @return string The HTML option elements for users.
798 561 */
799 - public function mlsimport_saas_prepare_meta_for_property( $property ) {
800 -
801 - if ( isset( $property['extra_meta']['BathroomsTotalDecimal'] ) && floatval( $property['extra_meta']['BathroomsTotalDecimal'] ) > 0 ) {
802 - $property['meta']['property_bathrooms'] = floatval( $property['extra_meta']['BathroomsTotalDecimal'] );
803 - $property['meta']['fave_property_bathrooms'] = floatval( $property['extra_meta']['BathroomsTotalDecimal'] );
804 - $property['meta']['REAL_HOMES_property_bathrooms'] = floatval( $property['extra_meta']['BathroomsTotalDecimal'] );
562 + public function mlsimportSaasThemeImportSelectUser($selected) {
563 + $userOptions = '';
564 + // Fetch all users to build a <select> of possible property authors.
565 + $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
566 + foreach ($blogusers as $user) {
567 + $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
568 + // Pre-select the currently chosen user.
569 + if ($user->ID == $selected) {
570 + $userOptions .= ' selected="selected"';
571 + }
572 + $userOptions .= '>' . esc_html($user->user_login) . '</option>';
805 573 }
806 - return $property;
574 + return $userOptions;
807 575 }
808 576
809 577
810 578
@@ -809,96 +577,57 @@
809 577
810 578
811 579
812 580
581 +
582 +
813 583 /**
814 - * attach media to post
584 + * Return agent option
815 585 *
816 - * @since 1.0.0
817 - * @access protected
818 - * @var string $plugin_name
586 + * @param int $selected The selected agent ID.
587 + * @return string The HTML option elements for agents.
819 588 */
820 - public function mlsimport_sass_attach_media_to_post( $property_id, $media, $is_insert ) {
821 -
822 -
823 - $media_history = array();
824 - if ( 'no' === $is_insert ) {
825 - $media_history[] = ' Media - We have edit - images are not replaced';
826 - return $media_history;
827 - }
828 -
589 + public function mlsimportSaasThemeImportSelectAgent($selected) {
829 590 global $mlsimport;
830 - include_once ABSPATH . 'wp-admin/includes/image.php';
831 - $has_featured = false;
832 - $all_images = array();
591 + // Query up to 150 published agents of the theme's agent post type.
592 + $args = [
593 + 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
594 + 'post_status' => 'publish',
595 + 'posts_per_page' => 150,
596 + ];
833 597
834 - delete_post_meta( $property_id, 'fave_property_images' );
835 - delete_post_meta( $property_id, 'REAL_HOMES_property_images' );
598 + $agentSelection = new WP_Query($args);
599 + // Start with a blank option (no agent).
600 + $agentOptions = '<option value=""></option>';
836 601
837 - add_filter( 'intermediate_image_sizes_advanced', array( $this, 'wpc_unset_imagesizes' ) );
602 + // Build one <option> per agent post.
603 + while ($agentSelection->have_posts()) {
604 + $agentSelection->the_post();
605 + $agentId = get_the_ID();
838 606
839 - // sorting media
840 - if ( isset( $media[0]['Order'] ) ) {
841 - $order = array_column( $media, 'Order' );
842 - array_multisort( $order, SORT_ASC, $media );
607 + $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
608 + // Pre-select the currently chosen agent.
609 + if ($agentId == $selected) {
610 + $agentOptions .= ' selected="selected"';
611 + }
612 + $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
843 613 }
614 + wp_reset_postdata();
844 615
845 - if ( is_array( $media ) ) {
846 - foreach ( $media as $key => $image ) :
847 - if ( isset( $image['MediaCategory'] ) && 'Photo' !== $image['MediaCategory'] ) {
848 - continue;
849 - }
616 + return $agentOptions;
617 + }
850 618
851 - $file = $image['MediaURL'];
852 619
853 - $media_url = '';
854 - if ( isset( $image['MediaURL'] ) ) {
855 - $attachment = array(
856 - 'guid' => $image['MediaURL'],
857 - 'post_status' => 'inherit',
858 - 'post_content' => '',
859 - 'post_parent' => $property_id,
860 - );
861 620
862 - if ( isset( $image['MimeType'] ) ) {
863 - $attachment['post_mime_type'] = $image['MimeType'];
864 - } else {
865 - $attachment['post_mime_type'] = 'image/jpg';
866 - }
867 621
868 - if ( isset( $image['MediaKey'] ) ) {
869 - $attachment['post_title'] = $image['MediaKey'];
870 - } else {
871 - $attachment['post_title'] = '';
872 - }
873 622
874 - $attach_id = wp_insert_attachment( $attachment, $file );
623 +
875 624
876 - $media_history[] = ' Media - Added ' . $image['MediaURL'] . ' as attachement ' . $attach_id;
877 - // wp_generate_attachment_metadata($attach_id,$image['MediaURL']);
878 - $mlsimport->admin->env_data->enviroment_image_save( $property_id, $attach_id );
879 625
880 - update_post_meta( $attach_id, 'is_mlsimport', 1 );
881 - if ( ! $has_featured ) {
882 - set_post_thumbnail( $property_id, $attach_id );
883 - $has_featured = true;
884 - }
885 - }
886 - endforeach;
887 - } else {
888 - $media_history[] = ' Media data is blank - there are no images';
889 - }
890 - remove_filter( 'intermediate_image_sizes_advanced', array( $this, 'wpc_unset_imagesizes' ) );
891 626
892 - $media_history = implode( '</br>', $media_history );
893 - return $media_history;
894 - }
895 627
896 628
897 629
898 - function wpc_unset_imagesizes( $sizes ) {
899 - $sizes = array();
900 - }
901 630
902 631
903 632
904 633
@@ -903,185 +632,306 @@
903 632
904 633
905 634
906 635
636 + /**
637 + * Delete property via SQL
638 + *
639 + * @param int $deleteId The ID of the property to delete.
640 + * @param string $ListingKey The listing key of the property.
641 + */
642 + public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
643 + global $mlsimport;
907 644
645 + // Resolve the post's type and the theme's expected property post type.
646 + $postType = get_post_type($deleteId);
647 + $propertyPostType = '';
648 + if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
649 + $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
650 + }
908 651
652 + // Only delete when the post is actually a property post type.
653 + if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
654 + // GitHub issue #287: capture the attachment IDs BEFORE any deletion
655 + // (they are found by post_parent, gone once the post row is), but do
656 + // NOT delete them yet. File deletion is the only irreversible step,
657 + // so it runs last — only after the post row is confirmed gone.
658 + $attachments = get_posts([
659 + 'numberposts' => -1,
660 + 'post_type' => 'attachment',
661 + 'post_parent' => $deleteId,
662 + 'post_status' => null,
663 + 'fields' => 'ids',
664 + ]);
909 665
666 + // Capture the current status term names for the delete log.
667 + $termObjList = get_the_terms($deleteId, 'property_status');
668 + $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
910 669
911 - /**
912 - * return user option
913 - *
914 - * @since 1.0.0
915 - * @access protected
916 - * @var string $plugin_name
917 - */
918 - public function mlsimport_saas_theme_import_select_user( $selected ) {
919 - $blog_list = '';
920 - $blogusers = get_users( 'blog_id=1&orderby=nicename' );
921 - foreach ( $blogusers as $user ) {
922 - $the_id = $user->ID;
923 - $blog_list .= '<option value="' . $the_id . '" ';
924 - if ( $the_id === intval($selected) ) {
925 - $blog_list .= ' selected="selected" ';
926 - }
927 - $blog_list .= '>' . $user->user_login . '</option>';
928 - }
929 - return $blog_list;
930 - }
670 + // Re-read the identity from protected meta (issue #286); an empty key
671 + // means a manually added listing.
672 + $ListingKey = get_post_meta($deleteId, '_mlsimport_listing_key', true);
673 + if ('' === $ListingKey) { // manually added listing
674 + // Never delete user-created listings; log and bail.
675 + $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
676 + $this->writeImportLogs($logEntry, 'delete');
677 + return;
678 + }
931 679
680 + // Capture the owning task before its meta row is deleted below; the
681 + // success activity entry still needs it afterward.
682 + $ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true));
932 683
684 + // Dedupe (issue #282): this raw-SQL path bypasses the WP delete
685 + // hooks, so capture the address group now (meta is gone after the
686 + // raw delete) and re-evaluate it after success — deleting a flagged
687 + // winner must promote its hidden loser.
688 + $dedupeAddressKey = (string) get_post_meta($deleteId, 'mlsimport_address_key', true);
689 + // Telemetry (#283): the deletion counts against the
690 + // listing's OWN connection — read the provenance stamp
691 + // (#278) before the raw delete wipes its meta.
692 + $provenanceMlsId = (int) get_post_meta($deleteId, 'mlsimport_mls_id', true);
933 693
694 + global $wpdb;
695 + // Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the
696 + // property's term relationships, term counts or listings row. Do that
697 + // cleanup explicitly (SQL-first) before removing the post itself.
698 + // Standalone mode: purge the plugin's own term/listings relations first.
699 + if ( class_exists( 'Mlsimport_Standalone_Row' ) ) {
700 + Mlsimport_Standalone_Row::purge_post_relations( $deleteId );
701 + }
702 + // Raw delete of the post's meta, then the post and any remaining
703 + // non-attachment children. Attachment rows and meta must survive this
704 + // step so wp_delete_attachment() below can still remove their files.
705 + $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
706 + $postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId));
934 707
708 + // GitHub issue #287: a failed post delete must leave the listing fully
709 + // intact — no attachment deletion, no "deleted" history, no telemetry.
710 + if (false === $postsDeleted || $postsDeleted < 1) {
711 + $logEntry = 'MYSQL DELETE FAILED -> Property with id ' . $deleteId . ' (' . $postType . ') and ' . $ListingKey . ' was NOT deleted; attachments left untouched' . PHP_EOL;
712 + $this->writeImportLogs($logEntry, 'delete');
713 + return;
714 + }
935 715
716 + // The post row is durably gone; removing the now-orphaned attachments
717 + // (rows, meta, and files) can no longer strand a visible listing.
718 + foreach ($attachments as $attachmentId) {
719 + wp_delete_attachment($attachmentId, true);
720 + }
936 721
722 + // Dedupe (issue #282): the post row is durably gone — settle the
723 + // surviving copies of its address group (promote a hidden loser).
724 + if ('' !== $dedupeAddressKey && function_exists('mlsimport_dedupe_evaluate')) {
725 + mlsimport_dedupe_evaluate($dedupeAddressKey, (string) $postType);
726 + }
937 727
728 + // Record the deletion in the activity feed only after it happened.
729 + mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' );
730 + mlsimport_telemetry_bump( 'deleted', 1, $provenanceMlsId );
938 731
939 - /**
940 - * return agent option
941 - *
942 - * @since 1.0.0
943 - * @access protected
944 - * @var string $plugin_name
945 - */
946 - public function mlsimport_saas_theme_import_select_agent( $selected ) {
947 - global $mlsimport;
948 - $args2 = array(
949 - 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
950 - 'post_status' => 'publish',
951 - 'posts_per_page' => 150,
952 - );
732 + $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
733 + $this->writeImportLogs($logEntry, 'delete');
734 + }
735 + }
953 736
954 - if ( method_exists( $mlsimport, 'get_agent_post_type' ) ) {
955 - $args2['post_type'] = $mlsimport->admin->env_data->get_agent_post_type();
956 - }
957 737
958 - $agent_selection2 = new WP_Query( $args2 );
959 - $agent_list_sec = '<option value=""><option>';
960 738
961 - while ( $agent_selection2->have_posts() ) {
962 - $agent_selection2->the_post();
963 - $the_id = get_the_ID();
964 739
965 - $agent_list_sec .= '<option value="' . $the_id . '" ';
966 - if ( intval($selected) === $the_id ) {
967 - $agent_list_sec .= ' selected="selected" ';
968 - }
969 - $agent_list_sec .= '>' . get_the_title() . '</option>';
970 - }
971 - wp_reset_postdata();
972 740
973 - return $agent_list_sec;
974 - }
975 741
976 742
977 - /**
978 - * delete property
979 - *
980 - * @since 1.0.0
981 - * @access protected
982 - * @var string $plugin_name
983 - */
984 - public function delete_property( $delete_id, $ListingKey ) {
985 - if ( intval( $delete_id ) > 0 ) {
986 - $arguments = array(
987 - 'numberposts' => -1,
988 - 'post_type' => 'attachment',
989 - 'post_parent' => $delete_id,
990 - 'post_status' => null,
991 - 'orderby' => 'menu_order',
992 - 'order' => 'ASC',
993 - );
994 - $post_attachments = get_posts( $arguments );
995 743
996 - foreach ( $post_attachments as $attachment ) {
997 - wp_delete_post( $attachment->ID );
998 - }
999 744
1000 - wp_delete_post( $delete_id );
1001 - $log_entry = ' Property with id ' . $delete_id . ' and ' . $ListingKey . ' was deleted on ' . current_time( 'Y-m-d\TH:i' ) . PHP_EOL;
1002 - mlsimport_saas_single_write_import_custom_logs( $log_entry, 'delete' );
1003 - }
745 +/**
746 + * Delegate one incoming property to the explicit Stored Listing Write module.
747 + *
748 + * ThemeImport translates the legacy Import Task option names once at this
749 + * compatibility edge. Listing decisions, common normalization, ordering,
750 + * persistence, media, activity, and terminal outcomes stay behind write().
751 + *
752 + * @param array<string, mixed> $property Raw RESO property.
753 + * @param array<string, mixed> $itemIdArray Import Task identity.
754 + * @param string $tipImport Manual or cron source.
755 + * @param array<string, mixed> $mlsimportItemOptionData Legacy task options.
756 + * @return array<string, mixed>|false Public write result, or false if unconfigured.
757 + */
758 +public function mlsimportSaasPrepareToImportPerItem( $property, $itemIdArray, $tipImport, $mlsimportItemOptionData ) {
759 + // A ThemeImport object used only for static SaaS/reconciliation helpers has
760 + // no writer. If a listing call reaches such an object, fail this item without
761 + // mutating WordPress; shared task execution will continue with the next one.
762 + if ( null === $this->stored_listing_write ) {
763 + $this->writeImportLogs(
764 + empty( $property['ListingKey'] )
765 + ? 'ERROR: No Listing Key ' . PHP_EOL
766 + : 'ERROR: Stored Listing Write is not configured.' . PHP_EOL,
767 + (string) $tipImport
768 + );
769 + return false;
1004 770 }
1005 771
772 + // Translate the shallow legacy option array into the stable module settings.
773 + // The listing's provenance (issue #278) is the task's OWN connection binding
774 + // (#277) read straight from post meta — deliberately NO current-connection
775 + // fallback on the write path (decision #266): an unbound task stamps 0
776 + // rather than silently adopting whichever connection is globally selected.
777 + $settings = array(
778 + 'task_id' => (int) ( $itemIdArray['item_id'] ?? 0 ),
779 + 'mls_id' => (int) get_post_meta( (int) ( $itemIdArray['item_id'] ?? 0 ), 'mlsimport_item_mls_id', true ),
780 + 'source' => (string) $tipImport,
781 + 'statuses' => is_array( $mlsimportItemOptionData['mlsimport_item_standardstatus'] ?? null )
782 + ? $mlsimportItemOptionData['mlsimport_item_standardstatus']
783 + : array(),
784 + 'user_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_property_user'] ?? 0 ),
785 + 'assigned_agent_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_agent'] ?? 0 ),
786 + 'use_mls_agent' => ! empty( $mlsimportItemOptionData['mlsimport_item_use_mls_agent'] ),
787 + 'post_status' => (string) ( $mlsimportItemOptionData['mlsimport_item_property_status'] ?? 'publish' ),
788 + 'field_configuration' => is_array( $mlsimportItemOptionData['mlsimport_field_configuration'] ?? null )
789 + ? $mlsimportItemOptionData['mlsimport_field_configuration']
790 + : array(),
791 + 'title_format' => (string) ( $mlsimportItemOptionData['mlsimport_item_title_format'] ?? '' ),
792 + 'config_version' => (string) ( $mlsimportItemOptionData['mlsimport_write_config_version'] ?? '' ),
793 + );
1006 794
795 + return $this->stored_listing_write->write( $property, $settings );
796 +}
1007 797
1008 798
799 +
800 +
801 +
1009 802
1010 803
804 +
805 +/**
806 + * Check for property status against MLS item delete status to see if we keep or delete the listing.
807 + * @param int $property_id
808 + * @param string|array $mlsImportItemStatus
809 + * @return bool True to keep, false to delete
810 + */
811 +public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1011 812
813 + // Resolve the taxonomy field-map, then read the property's current status term.
814 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
815 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
816 + ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
817 + $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1012 818
819 + // Protected statuses: keep if property status matches
820 + if (!empty($mlsImportItemStatusProtect)) {
821 + // An unreadable status cannot prove the listing is NOT protected — keep and log.
822 + if ('' === $post_status) {
823 + $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, cannot check it against Protected Statuses' . PHP_EOL, 'delete');
824 + return true;
825 + }
826 + // Normalise the protect list to space-free enum keys.
827 + $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
828 + ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
829 + : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
830 + // Property status is protected → keep it.
831 + if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
832 + return true;
833 + }
834 + }
1013 835
836 + // Default: delete if not protected
837 + return false;
838 +}
1014 839
1015 840
1016 - /**
1017 - * return_array with title items
1018 - *
1019 - * @since 1.0.0
1020 - * @access protected
1021 - * @var string $plugin_name
1022 - */
1023 - public function str_between_all( string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0 ) {
1024 - $strings = array();
1025 - $length = strlen( $string );
1026 841
1027 - while ( $offset < $length ) {
1028 - $found = $this->str_between( $string, $start, $end, $includeDelimiters, $offset );
1029 - if ( null === $found ) {
1030 - break;
1031 - }
1032 842
1033 - $strings[] = $found;
1034 - $offset += strlen( $includeDelimiters ? $found : $start . $found . $end ); // move offset to the end of the newfound string
1035 - }
843 +/**
844 + * Manual-import variant of the keep/delete status check.
845 + *
846 + * @param int $property_id The property post ID.
847 + * @param array|string $mlsImportItemStatus Task's selected statuses.
848 + * @return bool True if the property's status matches the selected set.
849 + */
850 +public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
851 + // Normalize status arrays/strings to a space-free comparison key so
852 + // Trestle PrettyEnums labels match the raw enum config values.
853 + $mlsImportItemStatus = is_array($mlsImportItemStatus)
854 + ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
855 + : mlsimport_normalize_status_enum($mlsImportItemStatus);
1036 856
1037 - return $strings;
1038 - }
857 + // Resolve the taxonomy field-map, then read the property's current status term.
858 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
859 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
860 + ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
861 + $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1039 862
863 + // An unreadable status is our read failing, not proof the listing should go — keep and log.
864 + if ('' === $post_status) {
865 + $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, deletion requires a readable status' . PHP_EOL, 'delete');
866 + return true;
867 + }
1040 868
869 + // Keep if status matches "keep" status (array membership or scalar equality).
870 + if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
871 + (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
872 +
873 + return true;
874 + }
1041 875
1042 876
1043 877
878 + // Default: status read but doesn't match the task's selection → delete.
879 + return false;
880 +}
1044 881
1045 882
1046 883
1047 884
1048 - /**
1049 - * str_between
1050 - *
1051 - * @since 1.0.0
1052 - * @access protected
1053 - * @var string $plugin_name
1054 - */
1055 - public function str_between( string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0 ) {
1056 - if ('' === $string || '' === $start || '' === $end ) {
1057 - return null;
1058 - }
1059 885
1060 - $startLength = strlen( $start );
1061 - $endLength = strlen( $end );
1062 886
1063 - $startPos = strpos( $string, $start, $offset );
1064 - if ( false === $startPos ) {
1065 - return null;
1066 - }
887 +
888 + /**
889 + * Check if we should keep or delete the listing when still in MLS.
890 + * true we keep
891 + */
892 + public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
893 + // Resolve the taxonomy field-map, then read the property's current status term.
894 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
895 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
896 + ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
897 + $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1067 898
1068 - $endPos = strpos( $string, $end, $startPos + $startLength );
1069 - if ( false === $endPos) {
1070 - return null;
1071 - }
899 + // The listing is still in the MLS feed. An unreadable local status is
900 + // never proof it should be deleted (every past mass-deletion incident
901 + // was this read failing) — keep and log.
902 + if ('' === $post_status) {
903 + $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: still in MLS feed but local status unreadable' . PHP_EOL, 'delete');
904 + return true;
905 + }
1072 906
1073 - $length = $endPos - $startPos + ( $includeDelimiters ? $endLength : -$startLength );
1074 - if ( ! $length ) {
1075 - return '';
1076 - }
907 + // Protected statuses: keep if property status matches
908 + if (!empty($mlsimport_item_standardstatusprotect)) {
909 + // Normalise the protect list to space-free enum keys.
910 + $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
911 + ? array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatusprotect)
912 + : array(mlsimport_normalize_status_enum($mlsimport_item_standardstatusprotect));
913 + // Protected → keep.
914 + if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
915 + return true;
916 + }
917 + }
1077 918
1078 - $offset = $startPos + ( $includeDelimiters ? 0 : $startLength );
919 + // Early return if MLS status empty
920 + if (empty($mlsimport_item_standardstatus)) {
921 + return true; // default: keep if no status set
922 + }
1079 923
1080 - $result = substr( $string, $offset, $length );
924 + // Normalize standard statuses to a space-free key for comparison
925 + if (is_array($mlsimport_item_standardstatus)) {
926 + // Array form → keep when the property's status is a member.
927 + $mlsimport_item_standardstatus = array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatus);
928 + return in_array($post_status, $mlsimport_item_standardstatus, true);
929 + }
930 + // Scalar form → keep on exact (normalised) match.
931 + return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus);
932 + }
1081 933
1082 - return ( false !== $result ? $result : null );
1083 - }
1084 934
1085 935
1086 936
1087 937
@@ -1088,59 +938,11 @@
1088 938
1089 939
1090 940
1091 941
1092 - /**
1093 - * delete property via sql
1094 - *
1095 - * @since 1.0.0
1096 - * @access protected
1097 - * @var string $plugin_name
1098 - */
1099 - public function mlsimport_saas_delete_property_via_mysql( $delete_id, $ListingKey ) {
1100 942
1101 - $post_type = get_post_type( $delete_id );
1102 943
1103 - if ( 'estate_property' === $post_type || 'property' === $post_type ) {
1104 - $term_obj_list = get_the_terms( $delete_id, 'property_status' );
1105 - $delete_id_status = join( ', ', wp_list_pluck( $term_obj_list, 'name' ) );
1106 944
1107 - $ListingKey = get_post_meta( $delete_id, 'ListingKey', true );
1108 - if ( '' === $ListingKey ) { // manual added listing
1109 - $log_entry = 'User added listing with id ' . $delete_id . ' (' . $post_type . ') (status ' . $delete_id_status . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1110 - mlsimport_saas_single_write_import_custom_logs( $log_entry, 'delete' );
1111 - return;
1112 - }
1113 945
1114 - global $wpdb;
1115 - $wpdb->query(
1116 - $wpdb->prepare(
1117 - "
1118 - DELETE FROM $wpdb->postmeta
1119 - WHERE `post_id` = %d",
1120 - $delete_id
1121 - )
1122 - );
1123 946
1124 - $wpdb->query(
1125 - $wpdb->prepare(
1126 - "
1127 - DELETE FROM $wpdb->posts
1128 - WHERE `post_parent` = %d",
1129 - $delete_id
1130 - )
1131 - );
1132 947
1133 - $wpdb->query(
1134 - $wpdb->prepare(
1135 - "
1136 - DELETE FROM $wpdb->posts
1137 - WHERE ID = %d",
1138 - $delete_id
1139 - )
1140 - );
1141 -
1142 - $log_entry = 'MYSQL DELETE -> Property with id ' . $delete_id . ' (' . $post_type . ') (status ' . $delete_id_status . ') and ' . $ListingKey . ' was deleted on ' . current_time( 'Y-m-d\TH:i' ) . PHP_EOL;
1143 - mlsimport_saas_single_write_import_custom_logs( $log_entry, 'delete' );
1144 - }
1145 - }
1146 948 }