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 -1071 5.7.57.2.1 View file →
@@ -1,1007 +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 - if(isset( $ready_to_parse_array['data'])){
181 - foreach ( $ready_to_parse_array['data'] as $key => $property ) {
182 - ++$counter_prop;
183 -
184 - $logs = $this->mlsimport_mem_usage() . '=== In parse search array, listing no ' . $key . ' from batch ' . $batch_key . ' with Listingkey: ' . $property['ListingKey'] . PHP_EOL;
185 - mlsimport_saas_single_write_import_custom_logs( $logs, 'import' );
186 -
187 -
188 - wp_cache_delete( 'mlsimport_force_stop_' . $item_id_array['item_id'], 'options' );
189 -
190 - $status = get_option( 'mlsimport_force_stop_' . $item_id_array['item_id'] );
191 - $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;
192 - mlsimport_saas_single_write_import_custom_logs( $logs, 'import' );
193 -
194 - if ( 'no' === $status ) {
195 - $logs = 'Will proceed to import - Memory Used ' . $this->mlsimport_mem_usage() . PHP_EOL;
196 - mlsimport_saas_single_write_import_custom_logs( $logs, 'import' );
197 - $this->mlsimport_saas_prepare_to_import_per_item( $property, $item_id_array, 'normal', $mlsimport_item_option_data );
198 - } else {
199 - update_post_meta( $item_id_array['item_id'], 'mlsimport_spawn_status', 'completed' );
200 - }
201 - unset( $logs );
202 - }
203 - }
204 -
205 - unset( $ready_to_parse_array );
206 - unset( $logs );
207 - }
208 -
209 -
210 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.
211 158 *
212 - *
213 - * 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.
214 162 */
215 - public function mlsimport_saas_cron_parse_search_array_per_item( $ready_to_parse_array, $item_id_array, $batch_key ) {
216 -
217 - $mlsimport_item_option_data = array();
218 - $mlsimport_item_option_data['mlsimport_item_standardstatus'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_standardstatus', true );
219 - $mlsimport_item_option_data['mlsimport_item_property_user'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_property_user', true );
220 - $mlsimport_item_option_data['mlsimport_item_agent'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_agent', true );
221 - $mlsimport_item_option_data['mlsimport_item_property_status'] = get_post_meta( $item_id_array['item_id'], 'mlsimport_item_property_status', true );
222 -
223 - foreach ( $ready_to_parse_array['data'] as $key => $property ) {
224 - $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batch_key . ' with Listingkey: ' . $property['ListingKey'] . PHP_EOL;
225 - mlsimport_saas_single_write_import_custom_logs( $logs, 'cron' );
226 - $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;
227 166 }
228 - }
229 167
168 + $token = self::getApiToken();
230 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 + );
231 183
184 + return true;
185 + }
232 186
233 187
234 -
235 -
236 188 /**
189 + * Blocking request to the SaaS API returning the decoded response.
237 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.
238 195 *
239 - * 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.
240 200 */
241 - 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 + }
242 213
243 - $args = array(
244 - 'post_type' => $post_type,
245 - 'post_status' => 'any',
246 214
247 - 'meta_query' => array(
248 - array(
249 - 'key' => 'ListingKey',
250 - 'value' => $key,
251 - 'compare' => '=',
252 - ),
253 - ),
254 - 'fields' => 'ids',
255 - );
215 + // Full endpoint URL.
216 + $url = MLSIMPORT_API_URL . $method;
256 217
257 - $prop_selection = new WP_Query( $args );
258 - if ( $prop_selection->have_posts() ) {
259 - while ( $prop_selection->have_posts() ) {
260 - $prop_selection->the_post();
261 - $the_id = get_the_ID();
262 - wp_reset_postdata();
263 - 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 + ];
264 226 }
265 - } else {
266 - wp_reset_postdata();
267 - return 0;
268 - }
269 - }
270 227
271 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);
272 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 + }
273 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 + }
274 264
275 - /**
276 - * clear taxonomy
277 - *
278 - * @since 1.0.0
279 - * @access protected
280 - * @var string $plugin_name
281 - */
282 - 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);
283 268
284 - if ( is_array( $taxonomies ) ) :
285 - foreach ( $taxonomies as $taxonomy => $term ) :
286 - if (is_wp_error($taxonomy)) {
287 - error_log('Error with taxonomy: ' . $taxonomy->get_error_message());
288 - continue; // Skip this iteration
289 - }
290 -
291 - if ( taxonomy_exists($taxonomy) ) {
292 - wp_delete_object_term_relationships($property_id, $taxonomy);
293 - } else {
294 - error_log("Taxonomy does not exist: {$taxonomy}");
295 - }
296 - endforeach;
297 - endif;
298 - }
269 + // 200 → return the decoded payload untouched.
270 + if (200 === $status_code) {
271 + $receivedData = json_decode($body, true);
272 + return $receivedData;
273 + }
299 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;
300 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 + }
301 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 + ];
302 299
300 + exit();
301 + }
303 302
304 303
305 -
306 -
304 +
307 305 /**
308 - * return non encoded encoded values
306 + * Check if token is expired and refresh if needed
307 + * Call this before any external API request
309 308 *
310 - * @since 1.0.0
311 - * @access protected
312 - * @var string $plugin_name
309 + * @return bool True if token is valid, false if refresh failed
313 310 */
314 - 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();
315 322
316 - if ( is_array( $item ) ) {
317 - if ( ! empty( $encoded_values ) ) {
318 - foreach ( $item as $key => $value ) {
319 - if ( isset( $encoded_values [ $value ] ) ) {
320 - $item[ $key ] = $encoded_values [ $value ];
321 - }
322 - }
323 + // Propagate refresh failure to the caller.
324 + if (!$refresh_result) {
325 + return false;
323 326 }
324 - return $item;
325 - } elseif ( ! empty( $encoded_values ) && isset( $encoded_values [ $item ] ) ) {
326 - return $encoded_values [ $item ];
327 - } else {
328 - return $item;
329 327 }
328 +
329 + // Token is present and not past expiry.
330 + return true;
330 331 }
331 332
332 333 /**
333 - * set taxonomy
334 + * Record the SaaS connection-health state (#208).
334 335 *
335 - * @since 1.0.0
336 - * @access protected
337 - * @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
338 346 */
339 -
340 - function mlsimport_saas_update_taxonomy_for_property_new($taxonomy, $property_id, $field_values) {
341 - global $wpdb;
342 -
343 - // Remove filters temporarily to avoid caching issues
344 - remove_filter('get_term_metadata', array($wpdb->terms, 'cache_term_counts'));
345 - wp_cache_delete('terms', 'cache');
346 -
347 - if (!is_array($field_values)) {
348 - if (strpos($field_values, ',') !== false) {
349 - $field_values = explode(',', $field_values);
350 - } else {
351 - $field_values = array($field_values);
352 - }
353 - }
354 -
355 - // Use transactions for better performance
356 - $wpdb->query('START TRANSACTION');
357 -
358 - // Process in smaller chunks
359 - foreach (array_chunk($field_values, 5) as $chunk) {
360 - foreach ($chunk as $value) {
361 - if (!empty($value)) {
362 - $term = get_term_by('name', $value, $taxonomy);
363 -
364 - if (is_wp_error($term) || empty($term)) {
365 - // Term doesn't exist, insert it
366 - $term = wp_insert_term($value, $taxonomy);
367 -
368 - if (!is_wp_error($term)) {
369 - $term_id = $term['term_id'];
370 - $term_taxonomy_id = $term['term_taxonomy_id'];
371 - }
372 - } else {
373 - // Term exists
374 - $term_id = $term->term_id;
375 - $term_taxonomy_id = $term->term_taxonomy_id;
376 - }
377 -
378 - if (!empty($term_id) && !empty($term_taxonomy_id)) {
379 - // Insert term relationship
380 - $wpdb->insert(
381 - $wpdb->term_relationships,
382 - array(
383 - 'object_id' => $property_id,
384 - 'term_taxonomy_id' => $term_taxonomy_id,
385 - 'term_order' => 0
386 - ),
387 - array(
388 - '%d',
389 - '%d',
390 - '%d'
391 - )
392 - );
393 - }
394 - }
395 - }
396 - }
397 -
398 - // Commit transaction
399 - $wpdb->query('COMMIT');
400 -
401 - // Clear term cache selectively
402 - wp_cache_delete("{$taxonomy}_terms", 'terms');
403 - wp_cache_delete("{$taxonomy}_children", 'terms');
404 -
405 - // Restore the term metadata filter
406 - add_filter('get_term_metadata', array($wpdb->terms, 'cache_term_counts'), 10, 2);
407 -}
408 -
409 -function mlsimport_saas_update_taxonomy_for_property($taxonomy, $property_id, $field_values) {
410 - global $wpdb;
411 -
412 - // Convert comma-separated values to array if necessary
413 - if (!is_array($field_values)) {
414 - $field_values = strpos($field_values, ',') !== false ? explode(',', $field_values) : array($field_values);
415 - }
416 -
417 - // Trim values and remove empty ones
418 - $field_values = array_filter(array_map('trim', $field_values));
419 -
420 - // Start a database transaction
421 - $wpdb->query('START TRANSACTION');
422 - $tax_log = array();
423 -
424 - foreach (array_chunk($field_values, 5) as $chunk) {
425 - foreach ($chunk as $value) {
426 - if (!empty($value)) {
427 - // Check if the term already exists
428 -
429 - $term = $wpdb->get_row($wpdb->prepare(
430 - "SELECT t.*, tt.* FROM $wpdb->terms t
431 - INNER JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id
432 - WHERE t.name = %s AND tt.taxonomy = %s",
433 - $value, $taxonomy
434 - ));
435 -
436 -
437 - $tax_log[] = json_encode($term);
438 - if (is_null($term)) {
439 - // Insert the term if it doesn't exist
440 -
441 - $wpdb->insert($wpdb->terms, array(
442 - 'name' => $value,
443 - 'slug' => sanitize_title($value),
444 - 'term_group' => 0
445 - ));
446 -
447 - $term_id = $wpdb->insert_id;
448 -
449 - if ($term_id) {
450 - // Insert term taxonomy
451 - $wpdb->insert($wpdb->term_taxonomy, array(
452 - 'term_id' => $term_id,
453 - 'taxonomy' => $taxonomy,
454 - 'description' => '',
455 - 'parent' => 0,
456 - 'count' => 0
457 - ));
458 -
459 - $term_taxonomy_id = $wpdb->insert_id;
460 - } else {
461 - $tax_log[] = 'Error inserting term';
462 - continue;
463 - }
464 - } else {
465 - // Term exists, get term_id and term_taxonomy_id
466 - $term_id = $term->term_id;
467 - $term_taxonomy_id = $wpdb->get_var($wpdb->prepare(
468 - "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s",
469 - $term_id,
470 - $taxonomy
471 - ));
472 - }
473 -
474 - if (!empty($term_taxonomy_id)) {
475 - // Insert term relationship
476 - $wpdb->replace($wpdb->term_relationships, array(
477 - 'object_id' => $property_id,
478 - 'term_taxonomy_id' => $term_taxonomy_id
479 - ));
480 - // Increment the term count
481 - $wpdb->query($wpdb->prepare(
482 - "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d",
483 - $term_taxonomy_id
484 - ));
485 - } else {
486 - $tax_log[] = 'Error: term_taxonomy_id is null';
487 - }
488 - }
489 - }
490 - // Flush the cache to free up memory
491 - wp_cache_flush();
492 - // Run garbage collection
493 - gc_collect_cycles();
494 - }
495 - // Commit the transaction
496 - $wpdb->query('COMMIT');
497 -
498 - // Clear term cache selectively
499 - wp_cache_delete("{$taxonomy}_terms", 'terms');
500 - wp_cache_delete("{$taxonomy}_children", 'terms');
501 -
502 - // Restore the term metadata filter
503 - add_filter('get_term_metadata', array($wpdb->terms, 'cache_term_counts'), 10, 2);
504 -
505 - // Log memory usage
506 - /*$tip_import='normal';
507 - if (!empty($tax_log)) {
508 - $tax_log_str = implode(PHP_EOL, $tax_log);
509 - mlsimport_saas_single_write_import_custom_logs($tax_log_str, $tip_import);
510 - unset($tax_log_str);
511 - }*/
512 -}
513 -
514 -
515 -
516 - public function mlsimport_saas_update_taxonomy_for_property2( $taxonomy, $property_id, $field_values ) {
517 -
518 - if ( ! is_array( $field_values ) ) {
519 - if ( strpos( $field_values, ',' ) !== false ) {
520 - $field_values = explode( ',', $field_values );
521 - } else {
522 - $field_values = array( $field_values );
523 - }
347 + private static function setConnectionHealth( $status ) {
348 + $health = get_option( 'mlsimport_connection_health', array() );
349 + if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) {
350 + return;
524 351 }
352 + update_option(
353 + 'mlsimport_connection_health',
354 + array(
355 + 'status' => $status,
356 + 'since' => time(),
357 + )
358 + );
525 359
526 - // Remove empty values and decode values in one pass
527 - $processed_field_values = array();
528 - foreach ( $field_values as $value ) {
529 - if ( ! empty( $value ) ) {
530 - $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' );
531 366 }
367 + } elseif ( function_exists( 'mlsimport_alert_open' ) ) {
368 + mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) );
532 369 }
533 -
534 - // Bulk update terms if array is not empty
535 - if ( ! empty( $processed_field_values ) ) {
536 - wp_set_object_terms( $property_id, $processed_field_values, $taxonomy, true );
537 - clean_term_cache( $property_id, $taxonomy );
538 - }
539 370 }
540 371
541 -
542 -
543 -
544 372 /**
545 - * Set Property Title
373 + * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
546 374 *
547 - * @since 1.0.0
548 - * @access protected
549 - * @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.
550 383 */
551 - public function mlsimport_saas_update_property_title( $property_id, $mls_import_post_id, $property ) {
552 -
384 + private static function refreshToken() {
553 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'] : '';
554 392
555 - $title_format = esc_html( get_post_meta( $mls_import_post_id, 'mlsimport_item_title_format', true ) );
556 -
557 - if ( '' === $title_format ) {
558 - $options = get_option( 'mlsimport_admin_mls_sync' );
559 - $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;
560 398 }
561 399
562 - $start = '{';
563 - $end = '}';
564 - $title_array = $this->str_between_all( $title_format, $start, $end );
565 -
566 - $property_extra_meta_array_lowecase = array_change_key_case( $property['extra_meta'], CASE_LOWER );
567 -
568 - foreach ( $title_array as $key => $value ) {
569 - $replace = '';
570 - if ( 'Address' === $value ) {
571 - if ( isset( $property['adr_title'] ) ) {
572 - $replace = $property['adr_title'];
573 - }
574 - $title_format = str_replace( '{Address}', $replace, $title_format );
575 - } elseif ( 'City' === $value ) {
576 - if ( isset( $property['adr_city'] ) ) {
577 - $replace = $property['adr_city'];
578 - }
579 - $title_format = str_replace( '{City}', $replace, $title_format );
580 - } elseif ( 'CountyOrParish' === $value ) {
581 - if ( isset( $property['adr_county'] ) ) {
582 - $replace = $property['adr_county'];
583 - }
584 - $title_format = str_replace( '{CountyOrParish}', $replace, $title_format );
585 - } elseif ( 'PropertyType' === $value ) {
586 - if ( isset( $property['adr_type'] ) ) {
587 - $replace = $property['adr_type'];
588 - }
589 - $title_format = str_replace( '{PropertyType}', $replace, $title_format );
590 - } elseif ( 'Bedrooms' === $value ) {
591 - if ( isset( $property['adr_bedrooms'] ) ) {
592 - $replace = $property['adr_bedrooms'];
593 - }
594 - $title_format = str_replace( '{Bedrooms}', $replace, $title_format );
595 - } elseif ( 'Bathrooms' === $value ) {
596 - if ( isset( $property['adr_bathrooms'] ) ) {
597 - $replace = $property['adr_bathrooms'];
598 - }
599 - $title_format = str_replace( '{Bathrooms}', $replace, $title_format );
600 - } elseif ( 'ListingKey' === $value ) {
601 - $replace = $property['ListingKey'];
602 - if ( '' !== $replace ) {
603 - $title_format = str_replace( '{ListingKey}', $replace, $title_format );
604 - }
605 - } elseif ( 'ListingId' === $value ) {
606 - if ( isset( $property['adr_listingid'] ) ) {
607 - $replace = $property['adr_listingid'];
608 - }
609 - if ( '' !== $replace ) {
610 - $title_format = str_replace( '{ListingId}', $replace, $title_format );
611 - }
612 - } elseif ( 'StateOrProvince' === $value ) {
613 - if ( isset( $property['extra_meta']['StateOrProvince'] ) ) {
614 - $replace = $property['extra_meta']['StateOrProvince'];
615 - }
616 - $title_format = str_replace( '{StateOrProvince}', $replace, $title_format );
617 - } elseif ( 'PostalCode' === $value ) {
618 - if ( isset( $property['meta']['property_zip'] ) ) {
619 - if ( is_array( $property['meta']['property_zip'] ) ) {
620 - $replace = strval( $property['meta']['property_zip'][0] );
621 - } else {
622 - $replace = strval( $property['meta']['property_zip'] );
623 - }
624 - } elseif ( isset( $property['meta']['fave_property_zip'] ) ) {
625 - if ( is_array( $property['meta']['fave_property_zip'] ) ) {
626 - $replace = strval( $property['meta']['fave_property_zip'][0] );
627 - } else {
628 - $replace = strval( $property['meta']['fave_property_zip'] );
629 - }
630 - }
631 -
632 - $title_format = str_replace( '{PostalCode}', $replace, $title_format );
633 - } elseif ( 'StreetNumberNumeric' === $value ) {
634 - if ( isset( $property_extra_meta_array_lowecase['streetnumbernumeric'] ) ) {
635 - $replace = $property_extra_meta_array_lowecase['streetnumbernumeric'];
636 - }
637 - $title_format = str_replace( '{StreetNumberNumeric}', $replace, $title_format );
638 - } elseif ( 'StreetName' === $value ) {
639 - if ( isset( $property_extra_meta_array_lowecase['streetname'] ) ) {
640 - $replace = $property_extra_meta_array_lowecase['streetname'];
641 - }
642 - $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;
643 409 }
410 + update_option( 'mlsimport_token_refresh_lock', time() );
644 411 }
645 412
646 - $post = array(
647 - 'ID' => $property_id,
648 - 'post_title' => $title_format,
649 - '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
650 427 );
428 +
429 + // Make token request
430 + $response = wp_remote_post($url, $args);
651 431
652 - 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 + }
653 438
654 - unset( $property_extra_meta_array_lowecase );
655 - return $title_format;
656 - }
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 );
657 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 + }
658 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']);
659 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);
660 470
471 + // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
472 + update_option('mlsimport_token_expiry', intval($data['expires']));
661 473
474 + // First successful SaaS account connection (lifecycle telemetry).
475 + mlsimport_telemetry_set_once( 'account_connected_at', time() );
662 476
477 + // Refresh finished — release the single-flight lock.
478 + delete_option( 'mlsimport_token_refresh_lock' );
663 479
480 + // A minted token proves the account works → back to healthy.
481 + self::setConnectionHealth( 'healthy' );
664 482
665 - /**
666 - *
667 - *
668 - *
669 - *
670 - * import property -prepare
671 - *
672 - * @since 1.0.0
673 - * @access protected
674 - * @var string $plugin_name
675 - */
676 - public function mlsimport_saas_prepare_to_import_per_item( $property, $item_id_array, $tip_import, $mlsimport_item_option_data ) {
677 - // check if MLS id is set
678 - set_time_limit(0);
679 - global $mlsimport;
483 + return true;
484 + }
680 485
681 486
682 - $mls_import_item_status = $mlsimport_item_option_data['mlsimport_item_standardstatus'];
683 - $new_author = $mlsimport_item_option_data['mlsimport_item_property_user'];
684 - $new_agent = $mlsimport_item_option_data['mlsimport_item_agent'];
685 - $property_status = $mlsimport_item_option_data['mlsimport_item_property_status'];
686 487
687 - if ( is_array( $mls_import_item_status ) ) {
688 - $mls_import_item_status = array_map( 'strtolower', $mls_import_item_status );
689 - }
690 488
691 -
692 - if ( ! isset( $property['ListingKey'] ) ) {
693 - $log = 'ERROR : No Listing Key ' . PHP_EOL;
694 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
695 - return;
696 - }
697 489
698 - ob_start();
699 490
700 - $ListingKey = $property['ListingKey'];
701 - $listing_post_type = $mlsimport->admin->env_data->get_property_post_type();
702 - $property_id = intval( $this->mlsimport_saas_retrive_property_by_id( $ListingKey, $listing_post_type ) );
703 - $status = 'Not Found';
704 - $property_history = array();
705 - if ( isset( $property['StandardStatus'] ) ) {
706 - $status = strtolower( $property['StandardStatus'] );
707 - } else {
708 - $status = strtolower( $property['extra_meta']['MlsStatus'] );
709 - }
710 491
711 - if ( 0 === intval( $property_id) ) {
712 - $is_insert = 'no';
713 - if (
714 - 'active' === $status || 'active under contract' === $status ||
715 - 'active with contract' === $status || 'activewithcontract' === $status ||
716 - 'status' === $status || 'activeundercontract' === $status ||
717 - 'comingsoon' === $status || 'coming soon' === $status ||
718 - 'pending' === $status
719 - ) {
720 - $is_insert = 'yes';
721 - if ( 'cron' === $tip_import ) {
722 - if ( ! in_array( $status, $mls_import_item_status ) ) {
723 - // REJECTED because not selected';
724 - $is_insert = 'no';
725 - }
726 - }
727 - }
728 - } else {
729 - $is_insert = 'no';
730 - }
731 492
732 - $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 +
733 494
734 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
735 495
736 - // content set and check
737 - $content = '';
738 - $submit_title = $ListingKey;
739 - if ( isset( $property['content'] ) ) {
740 - $content = $property['content'];
741 - }
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 + }
742 505
743 - // $new_author = get_post_meta($item_id_array['item_id'],'mlsimport_item_property_user',true );
744 - // $new_agent = esc_html(get_post_meta($item_id_array['item_id'], 'mlsimport_item_agent', true));
745 - // $property_status = esc_html(get_post_meta($item_id_array['item_id'], 'mlsimport_item_property_status', true));
746 506
747 - $mlsimport_item_option_data = null;
748 507
749 - if ( 'yes' === $is_insert ) {
750 - $post = array(
751 - 'post_title' => $submit_title,
752 - 'post_content' => $content,
753 - 'post_status' => $property_status,
754 - 'post_type' => $listing_post_type,
755 - 'post_author' => $new_author,
756 - );
757 508
758 - $property_id = wp_insert_post( $post );
509 +
759 510
760 - if ( is_wp_error( $property_id ) ) {
761 - $log = 'ERROR : on inserting ' . PHP_EOL;
762 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
763 - } else {
764 - update_post_meta( $property_id, 'ListingKey', $ListingKey );
765 - $property_history[] = date( 'F j, Y, g:i a' ) . ': We Inserted the property with Default title : ' . $submit_title . ' and received id:' . $property_id;
766 - }
767 511
768 - clean_post_cache( $property_id );
769 - } elseif ( 0 !== $property_id ) {
770 - $property_history = array();
771 - $property_history[] = get_post_meta( $property_id, 'mlsimport_property_history', true );
772 512
773 - $delete_statuses = array(
774 - 'incomplete' => 'incomplete',
775 - 'hold' => 'hold',
776 - 'canceled' => 'canceled',
777 - 'closed' => 'closed',
778 - 'delete' => 'delete',
779 - 'expired' => 'expired',
780 - 'withdrawn' => 'withdrawn',
781 - );
782 513
783 - if ( ! in_array( 'pending', $mls_import_item_status ) ) {
784 - $delete_statuses['pending'] = 'pending';
785 - }
786 514
787 - if ( in_array( $status, $delete_statuses ) ) {
788 - $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;
789 - $log = '--1---> ' . $log . wp_json_encode( $mls_import_item_status ) . PHP_EOL;
790 - if ( in_array( $status, $mls_import_item_status ) ) {
791 - $log .= '-2--> canceling the delete ' . PHP_EOL;
792 - } else {
793 - $log .= '--3--> proceed with the delete ' . PHP_EOL;
794 - $this->delete_property( $property_id, $ListingKey );
795 - }
796 515
797 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
798 - unset( $log );
799 516
800 - return;
801 - } else {
802 517
803 - $post = array(
804 - 'ID' => $property_id,
805 - 'post_content' => $content,
806 - 'post_type' => $listing_post_type,
807 - 'post_author' => $new_author,
808 - );
809 518
810 - $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>';
811 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
812 519
813 - $property_id = wp_update_post( $post );
814 520
815 - if ( is_wp_error( $property_id ) ) {
816 - $log = 'ERROR : on edit ' . PHP_EOL;
817 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
818 - } else {
819 - $submit_title = get_the_title( $property_id );
820 - $property_history[] = gmdate( 'F j, Y, g:i a' ) . ': Property with title: ' . $submit_title . ', id:' . $property_id . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
821 - }
822 521
823 - clean_post_cache( $property_id );
824 - }
825 - }
826 522
827 - //
828 - // Insert or edit POST ends her - START ADDING DETAILS
829 - //
830 523
831 - $encoded_values = array();// may be obsolote
832 524
833 - if ( intval( $property_id ) === 0 ) {
834 - mlsimport_saas_single_write_import_custom_logs( 'ERROR property id is 0' . PHP_EOL, $tip_import );
835 - return; // no point in going forward if no id
836 - }
837 525
838 - // Start working on Taxonomies
839 - //
840 - $log = PHP_EOL.$this->mlsimport_mem_usage() . '====before tax======'. PHP_EOL;
841 - mlsimport_saas_single_write_import_custom_logs( $log, $tip_import );
842 -
843 - $tax_log = array_fill(0, 1, 'Property with ID ' . $property_id . ' NO taxonomies found!');
844 -
845 - if ( isset( $property['taxonomies'] ) && is_array( $property['taxonomies'] ) ) {
846 -
847 - remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
848 - wp_cache_delete('get_ancestors', 'taxonomy');
849 526
850 - $this->mlsimport_saas_clear_property_for_taxonomy( $property_id, $property['taxonomies'] );
851 -
852 527
853 - foreach ( $property['taxonomies'] as $taxonomy => $term ) :
854 - wp_cache_delete("{$taxonomy}_term_counts", 'counts');
855 - $this->mlsimport_saas_update_taxonomy_for_property( $taxonomy, $property_id, $term );
856 - $property_history[] = 'Updatedx Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode( $term );
857 - $tax_log [] = 'Memory:' . $this->mlsimport_mem_usage() . ' Property with ID ' . $property_id . ' Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode( $term );
858 -
859 - endforeach;
860 528
861 -
862 - unset($property['taxonomies'] );
863 - gc_collect_cycles();
864 - add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
865 - delete_option('category_children');
866 - }
867 529
868 530
869 -
870 - // Only log if there are updates
871 - if (!empty($tax_log)) {
872 - $tax_log_str = implode(PHP_EOL, $tax_log);
873 - mlsimport_saas_single_write_import_custom_logs($tax_log_str, $tip_import);
874 - unset($tax_log_str);
875 - }
876 - wp_cache_flush();
877 531
878 - // Pre Meta jobs
879 - //
880 - $property = $this->mlsimport_saas_prepare_meta_for_property( $property );
881 532
882 - // Start working on Meta
883 - //
884 533
885 - $meta_log = array();
886 - $meta_log [] = 'Property with ID ' . $property_id . ' NO meta found ! ' . PHP_EOL;
887 534
888 - if ( isset( $property['meta'] ) && is_array( $property['meta'] ) ) {
889 - $meta_properties = $property['meta'];
890 - foreach ( $meta_properties as $meta_name => $meta_value ) :
891 - if ( is_array( $meta_value ) ) {
892 - $meta_value = implode( ',', $meta_value );
893 - }
894 - update_post_meta( $property_id, $meta_name, $meta_value );
895 535
896 - $property_history[] = 'Updated Meta ' . $meta_name . ' with meta_value ' . $meta_value;
897 - $meta_log [] = 'Memory:' . $this->mlsimport_mem_usage() . 'Property with ID ' . $property_id . ' Updated Meta ' . $meta_name . ' with value ' . $meta_value;
898 - endforeach;
899 - }
900 - $meta_properties = null;
901 - $meta_log = implode( PHP_EOL, $meta_log );
902 - mlsimport_saas_single_write_import_custom_logs( $meta_log, $tip_import );
903 - $meta_log = null;
904 536
905 - // Start working on EXTRA Meta
906 - //
537 +
907 538
908 - $extra_meta_log = 'Property with ID ' . $property_id . ' Start Extra meta ! ' . PHP_EOL;
909 - $extra_meta_result = $mlsimport->admin->env_data->mlsimport_saas_set_extra_meta( $property_id, $property );
910 539
911 - if ( isset( $extra_meta_result['property_history'] ) ) {
912 - $property_history = array_merge( $property_history, (array) $extra_meta_result['property_history'] );
913 - }
914 - if ( isset( $extra_meta_result['extra_meta_log'] ) ) {
915 - $extra_meta_log .= $extra_meta_result['extra_meta_log'];
916 - }
917 540
918 - mlsimport_saas_single_write_import_custom_logs( $extra_meta_log, $tip_import );
919 541
920 - $extra_meta_log = null;
921 - $extra_meta_result = null;
922 542
923 - // Start working on Property Media
924 - //
925 543
926 - $media = $property['Media'];
927 - $media_history = $this->mlsimport_sass_attach_media_to_post( $property_id, $media, $is_insert );
928 - $property_history = array_merge( $property_history, (array) $media_history );
929 - $media = null;
930 - $media_history = null;
931 544
932 - // Updateing property title and ending
933 - //
934 545
935 - $new_title = $this->mlsimport_saas_update_property_title( $property_id, $item_id_array['item_id'], $property );
936 - $property_history[] = 'Updated title to ' . $new_title . '</br>';
546 +
937 547
938 - // extra fields to be checked
939 - $global_extra_fields = array();
940 - $mlsimport->admin->env_data->correlation_update_after( $is_insert, $property_id, $global_extra_fields, $new_agent );
941 548
942 - // saving history
943 - if ( ! empty( $property_history ) ) {
944 - $disable_history = intval( get_option( 'mlsimport-disable-history', 1 ) );
945 - if ( 1 === intval( $disable_history ) ) {
946 - $property_history[] = '---------------------------------------------------------------</br>';
947 - $property_history = implode( '</br>', $property_history );
948 - update_post_meta( $property_id, 'mlsimport_property_history', $property_history );
949 - }
950 - }
951 549
952 - $logs = PHP_EOL . 'Ending on Property ' . $property_id . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $is_insert . ' with new title: ' . $new_title . ' ' . PHP_EOL;
953 - mlsimport_saas_single_write_import_custom_logs( $logs, $tip_import );
954 550
955 - $capture = ob_get_contents();
956 - ob_end_clean();
957 - mlsimport_saas_single_write_import_custom_logs( $capture, $tip_import );
958 551
959 - $post = null;
960 - $capture = null;
961 - $property_status = null;
962 - $new_agent = null;
963 - $new_author = null;
964 - $property_history = null;
965 - $tax_log = null;
966 - $meta_log = null;
967 - $extra_meta_log = null;
968 - $media_history = null;
969 - $logs = null;
970 - $capture = null;
971 - clean_post_cache( $property_id );
972 - wp_cache_flush();
973 - gc_collect_cycles();
974 - }
975 552
976 553
977 554
978 555
979 -
980 -
981 -
982 - public function mlsimport_mem_usage() {
983 - $mem_usage = memory_get_usage( true );
984 - $mem_usage_show = round( $mem_usage / 1048576, 2 );
985 - return $mem_usage_show . 'mb ';
986 - }
987 -
988 -
989 556 /**
990 - * prepare meta data
557 + * Return user option
991 558 *
992 - * @since 1.0.0
993 - * @access protected
994 - * @var string $plugin_name
559 + * @param int $selected The selected user ID.
560 + * @return string The HTML option elements for users.
995 561 */
996 - public function mlsimport_saas_prepare_meta_for_property( $property ) {
997 -
998 - if ( isset( $property['extra_meta']['BathroomsTotalDecimal'] ) && floatval( $property['extra_meta']['BathroomsTotalDecimal'] ) > 0 ) {
999 - $property['meta']['property_bathrooms'] = floatval( $property['extra_meta']['BathroomsTotalDecimal'] );
1000 - $property['meta']['fave_property_bathrooms'] = floatval( $property['extra_meta']['BathroomsTotalDecimal'] );
1001 - $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>';
1002 573 }
1003 - return $property;
574 + return $userOptions;
1004 575 }
1005 576
1006 577
1007 578
@@ -1006,96 +577,57 @@
1006 577
1007 578
1008 579
1009 580
581 +
582 +
1010 583 /**
1011 - * attach media to post
584 + * Return agent option
1012 585 *
1013 - * @since 1.0.0
1014 - * @access protected
1015 - * @var string $plugin_name
586 + * @param int $selected The selected agent ID.
587 + * @return string The HTML option elements for agents.
1016 588 */
1017 - public function mlsimport_sass_attach_media_to_post( $property_id, $media, $is_insert ) {
1018 -
1019 -
1020 - $media_history = array();
1021 - if ( 'no' === $is_insert ) {
1022 - $media_history[] = ' Media - We have edit - images are not replaced';
1023 - return $media_history;
1024 - }
1025 -
589 + public function mlsimportSaasThemeImportSelectAgent($selected) {
1026 590 global $mlsimport;
1027 - include_once ABSPATH . 'wp-admin/includes/image.php';
1028 - $has_featured = false;
1029 - $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 + ];
1030 597
1031 - delete_post_meta( $property_id, 'fave_property_images' );
1032 - 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>';
1033 601
1034 - 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();
1035 606
1036 - // sorting media
1037 - if ( isset( $media[0]['Order'] ) ) {
1038 - $order = array_column( $media, 'Order' );
1039 - 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>';
1040 613 }
614 + wp_reset_postdata();
1041 615
1042 - if ( is_array( $media ) ) {
1043 - foreach ( $media as $key => $image ) :
1044 - if ( isset( $image['MediaCategory'] ) && 'Photo' !== $image['MediaCategory'] ) {
1045 - continue;
1046 - }
616 + return $agentOptions;
617 + }
1047 618
1048 - $file = $image['MediaURL'];
1049 619
1050 - $media_url = '';
1051 - if ( isset( $image['MediaURL'] ) ) {
1052 - $attachment = array(
1053 - 'guid' => $image['MediaURL'],
1054 - 'post_status' => 'inherit',
1055 - 'post_content' => '',
1056 - 'post_parent' => $property_id,
1057 - );
1058 620
1059 - if ( isset( $image['MimeType'] ) ) {
1060 - $attachment['post_mime_type'] = $image['MimeType'];
1061 - } else {
1062 - $attachment['post_mime_type'] = 'image/jpg';
1063 - }
1064 621
1065 - if ( isset( $image['MediaKey'] ) ) {
1066 - $attachment['post_title'] = $image['MediaKey'];
1067 - } else {
1068 - $attachment['post_title'] = '';
1069 - }
1070 622
1071 - $attach_id = wp_insert_attachment( $attachment, $file );
623 +
1072 624
1073 - $media_history[] = ' Media - Added ' . $image['MediaURL'] . ' as attachement ' . $attach_id;
1074 - // wp_generate_attachment_metadata($attach_id,$image['MediaURL']);
1075 - $mlsimport->admin->env_data->enviroment_image_save( $property_id, $attach_id );
1076 625
1077 - update_post_meta( $attach_id, 'is_mlsimport', 1 );
1078 - if ( ! $has_featured ) {
1079 - set_post_thumbnail( $property_id, $attach_id );
1080 - $has_featured = true;
1081 - }
1082 - }
1083 - endforeach;
1084 - } else {
1085 - $media_history[] = ' Media data is blank - there are no images';
1086 - }
1087 - remove_filter( 'intermediate_image_sizes_advanced', array( $this, 'wpc_unset_imagesizes' ) );
1088 626
1089 - $media_history = implode( '</br>', $media_history );
1090 - return $media_history;
1091 - }
1092 627
1093 628
1094 629
1095 - function wpc_unset_imagesizes( $sizes ) {
1096 - $sizes = array();
1097 - }
1098 630
1099 631
1100 632
1101 633
@@ -1100,185 +632,306 @@
1100 632
1101 633
1102 634
1103 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;
1104 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 + }
1105 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 + ]);
1106 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')) : '';
1107 669
1108 - /**
1109 - * return user option
1110 - *
1111 - * @since 1.0.0
1112 - * @access protected
1113 - * @var string $plugin_name
1114 - */
1115 - public function mlsimport_saas_theme_import_select_user( $selected ) {
1116 - $blog_list = '';
1117 - $blogusers = get_users( 'blog_id=1&orderby=nicename' );
1118 - foreach ( $blogusers as $user ) {
1119 - $the_id = $user->ID;
1120 - $blog_list .= '<option value="' . $the_id . '" ';
1121 - if ( $the_id === intval($selected) ) {
1122 - $blog_list .= ' selected="selected" ';
1123 - }
1124 - $blog_list .= '>' . $user->user_login . '</option>';
1125 - }
1126 - return $blog_list;
1127 - }
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 + }
1128 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));
1129 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);
1130 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));
1131 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 + }
1132 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 + }
1133 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 + }
1134 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 );
1135 731
1136 - /**
1137 - * return agent option
1138 - *
1139 - * @since 1.0.0
1140 - * @access protected
1141 - * @var string $plugin_name
1142 - */
1143 - public function mlsimport_saas_theme_import_select_agent( $selected ) {
1144 - global $mlsimport;
1145 - $args2 = array(
1146 - 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
1147 - 'post_status' => 'publish',
1148 - 'posts_per_page' => 150,
1149 - );
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 + }
1150 736
1151 - if ( method_exists( $mlsimport, 'get_agent_post_type' ) ) {
1152 - $args2['post_type'] = $mlsimport->admin->env_data->get_agent_post_type();
1153 - }
1154 737
1155 - $agent_selection2 = new WP_Query( $args2 );
1156 - $agent_list_sec = '<option value=""><option>';
1157 738
1158 - while ( $agent_selection2->have_posts() ) {
1159 - $agent_selection2->the_post();
1160 - $the_id = get_the_ID();
1161 739
1162 - $agent_list_sec .= '<option value="' . $the_id . '" ';
1163 - if ( intval($selected) === $the_id ) {
1164 - $agent_list_sec .= ' selected="selected" ';
1165 - }
1166 - $agent_list_sec .= '>' . get_the_title() . '</option>';
1167 - }
1168 - wp_reset_postdata();
1169 740
1170 - return $agent_list_sec;
1171 - }
1172 741
1173 742
1174 - /**
1175 - * delete property
1176 - *
1177 - * @since 1.0.0
1178 - * @access protected
1179 - * @var string $plugin_name
1180 - */
1181 - public function delete_property( $delete_id, $ListingKey ) {
1182 - if ( intval( $delete_id ) > 0 ) {
1183 - $arguments = array(
1184 - 'numberposts' => -1,
1185 - 'post_type' => 'attachment',
1186 - 'post_parent' => $delete_id,
1187 - 'post_status' => null,
1188 - 'orderby' => 'menu_order',
1189 - 'order' => 'ASC',
1190 - );
1191 - $post_attachments = get_posts( $arguments );
1192 743
1193 - foreach ( $post_attachments as $attachment ) {
1194 - wp_delete_post( $attachment->ID );
1195 - }
1196 744
1197 - wp_delete_post( $delete_id );
1198 - $log_entry = ' Property with id ' . $delete_id . ' and ' . $ListingKey . ' was deleted on ' . current_time( 'Y-m-d\TH:i' ) . PHP_EOL;
1199 - mlsimport_saas_single_write_import_custom_logs( $log_entry, 'delete' );
1200 - }
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;
1201 770 }
1202 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 + );
1203 794
795 + return $this->stored_listing_write->write( $property, $settings );
796 +}
1204 797
1205 798
799 +
800 +
801 +
1206 802
1207 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) {
1208 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);
1209 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 + }
1210 835
836 + // Default: delete if not protected
837 + return false;
838 +}
1211 839
1212 840
1213 - /**
1214 - * return_array with title items
1215 - *
1216 - * @since 1.0.0
1217 - * @access protected
1218 - * @var string $plugin_name
1219 - */
1220 - public function str_between_all( string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0 ) {
1221 - $strings = array();
1222 - $length = strlen( $string );
1223 841
1224 - while ( $offset < $length ) {
1225 - $found = $this->str_between( $string, $start, $end, $includeDelimiters, $offset );
1226 - if ( null === $found ) {
1227 - break;
1228 - }
1229 842
1230 - $strings[] = $found;
1231 - $offset += strlen( $includeDelimiters ? $found : $start . $found . $end ); // move offset to the end of the newfound string
1232 - }
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);
1233 856
1234 - return $strings;
1235 - }
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);
1236 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 + }
1237 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 + }
1238 875
1239 876
1240 877
878 + // Default: status read but doesn't match the task's selection → delete.
879 + return false;
880 +}
1241 881
1242 882
1243 883
1244 884
1245 - /**
1246 - * str_between
1247 - *
1248 - * @since 1.0.0
1249 - * @access protected
1250 - * @var string $plugin_name
1251 - */
1252 - public function str_between( string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0 ) {
1253 - if ('' === $string || '' === $start || '' === $end ) {
1254 - return null;
1255 - }
1256 885
1257 - $startLength = strlen( $start );
1258 - $endLength = strlen( $end );
1259 886
1260 - $startPos = strpos( $string, $start, $offset );
1261 - if ( false === $startPos ) {
1262 - return null;
1263 - }
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);
1264 898
1265 - $endPos = strpos( $string, $end, $startPos + $startLength );
1266 - if ( false === $endPos) {
1267 - return null;
1268 - }
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 + }
1269 906
1270 - $length = $endPos - $startPos + ( $includeDelimiters ? $endLength : -$startLength );
1271 - if ( ! $length ) {
1272 - return '';
1273 - }
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 + }
1274 918
1275 - $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 + }
1276 923
1277 - $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 + }
1278 933
1279 - return ( false !== $result ? $result : null );
1280 - }
1281 934
1282 935
1283 936
1284 937
@@ -1285,59 +938,11 @@
1285 938
1286 939
1287 940
1288 941
1289 - /**
1290 - * delete property via sql
1291 - *
1292 - * @since 1.0.0
1293 - * @access protected
1294 - * @var string $plugin_name
1295 - */
1296 - public function mlsimport_saas_delete_property_via_mysql( $delete_id, $ListingKey ) {
1297 942
1298 - $post_type = get_post_type( $delete_id );
1299 943
1300 - if ( 'estate_property' === $post_type || 'property' === $post_type ) {
1301 - $term_obj_list = get_the_terms( $delete_id, 'property_status' );
1302 - $delete_id_status = join( ', ', wp_list_pluck( $term_obj_list, 'name' ) );
1303 944
1304 - $ListingKey = get_post_meta( $delete_id, 'ListingKey', true );
1305 - if ( '' === $ListingKey ) { // manual added listing
1306 - $log_entry = 'User added listing with id ' . $delete_id . ' (' . $post_type . ') (status ' . $delete_id_status . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1307 - mlsimport_saas_single_write_import_custom_logs( $log_entry, 'delete' );
1308 - return;
1309 - }
1310 945
1311 - global $wpdb;
1312 - $wpdb->query(
1313 - $wpdb->prepare(
1314 - "
1315 - DELETE FROM $wpdb->postmeta
1316 - WHERE `post_id` = %d",
1317 - $delete_id
1318 - )
1319 - );
1320 946
1321 - $wpdb->query(
1322 - $wpdb->prepare(
1323 - "
1324 - DELETE FROM $wpdb->posts
1325 - WHERE `post_parent` = %d",
1326 - $delete_id
1327 - )
1328 - );
1329 947
1330 - $wpdb->query(
1331 - $wpdb->prepare(
1332 - "
1333 - DELETE FROM $wpdb->posts
1334 - WHERE ID = %d",
1335 - $delete_id
1336 - )
1337 - );
1338 -
1339 - $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;
1340 - mlsimport_saas_single_write_import_custom_logs( $log_entry, 'delete' );
1341 - }
1342 - }
1343 948 }