PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2.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 +582 -758 5.8.47.2 View file →
@@ -1,21 +1,55 @@
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 - * @class ThemeImport
21 + * Expose legacy API/batch methods around the explicit listing-write module.
10 22 */
11 23 class ThemeImport {
12 24
13 25
26 + // Active theme adapter / identifier (set by callers).
14 27 public $theme;
28 + // Plugin slug/name carried for logging and context.
15 29 public $plugin_name;
30 + // Environment adapter instance (theme-specific meta mapping).
16 31 public $enviroment;
32 + // Cached encoded credential/config values.
17 33 public $encoded_values;
34 +
35 + /** @var object|null Injected Stored Listing Write module. */
36 + private $stored_listing_write;
37 +
38 + /**
39 + * Configure the API client and optional Stored mode write boundary.
40 + *
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.
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 + }
18 52
19 53
20 54 /**
21 55 * Api Request to MLSimport API using CURL
@@ -26,20 +60,36 @@
26 60 * @return mixed The API response or error message.
27 61 */
28 62
29 63 public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') {
64 +
65 +
30 66 global $mlsimport;
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 + }
76 +
77 + // Build the full endpoint URL from the SaaS base + method path.
31 78 $url = MLSIMPORT_API_URL . $method;
79 + // Default headers for the token request (plain text body).
32 80 $headers = ['Content-Type' => 'text/plain'];
33 81
82 + // For authenticated calls, swap to JSON + Bearer token headers.
34 83 if ($method !== 'token') {
35 84 $token = self::getApiToken();
36 85 $headers = [
37 86 'Content-Type' => 'application/json',
38 - 'authorizationToken' => $token,
87 + 'Authorization' => 'Bearer '.$token,
39 88 ];
40 89 }
41 -
90 +
91 + // Assemble the wp_remote_* argument array (long timeout for large payloads).
42 92 $args = [
43 93 'method' => $type,
44 94 'headers' => $headers,
45 95 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
@@ -49,17 +99,29 @@
49 99 'blocking' => true,
50 100 'user-agent' => $_SERVER['HTTP_USER_AGENT'],
51 101 ];
52 102
103 +
104 + // Dispatch as GET or POST depending on $type.
53 105 $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
54 106
55 -
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 + }
56 116
57 -
117 + // Transport-level failure: return the WP_Error message string.
58 118 if (is_wp_error($response)) {
59 119 return $response->get_error_message();
60 120 } else {
121 + // Otherwise decode the JSON body and return the array (or a decode-error string).
61 122 $body = wp_remote_retrieve_body($response);
123 +
62 124 $toReturn = json_decode($body, true);
63 125 if (json_last_error() !== JSON_ERROR_NONE) {
64 126 return 'JSON decode error: ' . json_last_error_msg();
65 127 }
@@ -87,22 +149,85 @@
87 149 * @param string $type The request type (default is 'GET').
88 150 * @return array The API response data.
89 151 */
90 152
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.
158 + *
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.
162 + */
163 + public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool {
164 + if ( ! self::validateAndRefreshToken() ) {
165 + return false;
166 + }
167 +
168 + $token = self::getApiToken();
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 + );
183 +
184 + return true;
185 + }
186 +
187 +
188 + /**
189 + * Blocking request to the SaaS API returning the decoded response.
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.
195 + *
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.
200 + */
91 201 public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') {
92 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 + }
213 +
214 +
215 + // Full endpoint URL.
93 216 $url = MLSIMPORT_API_URL . $method;
94 217
218 + // Attach Bearer auth headers for authenticated methods only.
95 219 $headers = [];
96 220 if ($method !== 'token' && $method !== 'mls') {
97 221 $token = self::getApiToken();
98 222 $headers = [
99 - 'authorizationToken' => $token,
223 + 'Authorization' => 'Bearer '.$token,
100 224 'Content-Type' => 'application/json',
101 225 ];
102 226 }
103 227
104 228
229 + // Request arguments (note: always dispatched via wp_remote_post below).
105 230 $args = [
106 231 'method' => $type,
107 232 'timeout' => 45,
108 233 'redirection' => 5,
@@ -111,11 +236,25 @@
111 236 'headers' => $headers,
112 237 'cookies' => [],
113 238 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
114 239 ];
240 + // Always POST (even for logical GETs) — the SaaS expects a JSON body.
115 241 $response = wp_remote_post($url, $args);
116 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 + }
117 255
256 + // Transport error → structured failure with WP error code/message.
118 257 if (is_wp_error($response)) {
119 258 return [
120 259 'success' => false,
121 260 'error_code' => $response->get_error_code(),
@@ -122,485 +261,299 @@
122 261 'error_message' => esc_html($response->get_error_message())
123 262 ];
124 263 }
125 264
126 - if (isset($response['response']['code']) && $response['response']['code'] === 200) {
127 - $receivedData = json_decode(wp_remote_retrieve_body($response), true);
128 - return $receivedData;
129 - } else {
130 - return ['success' => false];
131 - }
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);
132 268
133 - exit();
134 - }
269 + // 200 → return the decoded payload untouched.
270 + if (200 === $status_code) {
271 + $receivedData = json_decode($body, true);
272 + return $receivedData;
273 + }
135 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;
136 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 + }
137 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 + ];
138 299
300 + exit();
301 + }
139 302
140 303
141 -
142 -
304 +
143 305 /**
144 - * Parse Result Array
306 + * Check if token is expired and refresh if needed
307 + * Call this before any external API request
145 308 *
146 - * @param array $readyToParseArray The array ready to be parsed.
147 - * @param array $itemIdArray The item ID array.
148 - * @param string $batchKey The batch key.
149 - * @param array $mlsimportItemOptionData The item option data.
309 + * @return bool True if token is valid, false if refresh failed
150 310 */
311 + private static function validateAndRefreshToken() {
312 + global $mlsimport;
151 313
152 - public function mlsimportSaasParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey, $mlsimportItemOptionData) {
153 - $logs = '';
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();
154 322
155 - wp_cache_flush();
156 - gc_collect_cycles();
157 - $counterProp = 0;
158 -
159 - if (isset($readyToParseArray['data'])) {
160 - foreach ($readyToParseArray['data'] as $key => $property) {
161 - ++$counterProp;
162 -
163 - $logs = $this->mlsimportMemUsage() . '=== In parse search array, listing no ' . $key . ' from batch ' . $batchKey . ' with ListingKey: ' . $property['ListingKey'] . PHP_EOL;
164 - $this->writeImportLogs($logs, 'import');
165 -
166 - wp_cache_delete('mlsimport_force_stop_' . $itemIdArray['item_id'], 'options');
167 -
168 - $status = get_option('mlsimport_force_stop_' . $itemIdArray['item_id']);
169 - $logs = $this->mlsimportMemUsage() . ' / on Batch ' . $itemIdArray['batch_counter'] . ', Item ID: ' . $itemIdArray['item_id'] . '/' . $counterProp . ' check ListingKey ' . $property['ListingKey'] . ' - stop command issued ? ' . $status . PHP_EOL;
170 - $this->writeImportLogs($logs, 'import');
171 -
172 - if ($status === 'no') {
173 - $logs = 'Will proceed to import - Memory Used ' . $this->mlsimportMemUsage() . PHP_EOL;
174 - $this->writeImportLogs($logs, 'import');
175 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'normal', $mlsimportItemOptionData);
176 - } else {
177 - update_post_meta($itemIdArray['item_id'], 'mlsimport_spawn_status', 'completed');
178 - }
179 - unset($logs);
323 + // Propagate refresh failure to the caller.
324 + if (!$refresh_result) {
325 + return false;
180 326 }
181 327 }
182 328
183 - unset($readyToParseArray);
184 - unset($logs);
329 + // Token is present and not past expiry.
330 + return true;
185 331 }
186 332
187 -
188 333 /**
189 - * Write logs for import process
334 + * Record the SaaS connection-health state (#208).
190 335 *
191 - * @param string $logs The log message to write.
192 - * @param string $type The type of log.
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
193 346 */
194 - private function writeImportLogs($logs, $type) {
195 - mlsimport_saas_single_write_import_custom_logs($logs, $type);
347 + private static function setConnectionHealth( $status ) {
348 + $health = get_option( 'mlsimport_connection_health', array() );
349 + if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) {
350 + return;
351 + }
352 + update_option(
353 + 'mlsimport_connection_health',
354 + array(
355 + 'status' => $status,
356 + 'since' => time(),
357 + )
358 + );
359 +
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' );
366 + }
367 + } elseif ( function_exists( 'mlsimport_alert_open' ) ) {
368 + mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) );
369 + }
196 370 }
197 371
198 372 /**
199 - * Get memory usage
373 + * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
200 374 *
201 - * @return string The memory usage in MB.
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.
202 383 */
203 - public function mlsimportMemUsage() {
204 - $memUsage = memory_get_usage(true);
205 - $memUsageShow = round($memUsage / 1048576, 2);
206 - return $memUsageShow . 'mb ';
207 - }
384 + private static function refreshToken() {
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'] : '';
208 392
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;
398 + }
209 399
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;
409 + }
410 + update_option( 'mlsimport_token_refresh_lock', time() );
411 + }
210 412
211 -
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
427 + );
428 +
429 + // Make token request
430 + $response = wp_remote_post($url, $args);
212 431
213 - /**
214 - * Parse Result Array in CRON
215 - *
216 - * @param array $readyToParseArray The array ready to be parsed.
217 - * @param array $itemIdArray The item ID array.
218 - * @param string $batchKey The batch key.
219 - */
220 - public function mlsimportSaasCronParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey) {
221 - $mlsimportItemOptionData = [
222 - 'mlsimport_item_standardstatus' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatus', true),
223 - 'mlsimport_item_standardstatusdelete' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatusdelete', true),
224 - 'mlsimport_item_property_user' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_user', true),
225 - 'mlsimport_item_agent' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_agent', true),
226 - 'mlsimport_item_property_status' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_status', true),
227 - ];
228 -
229 - foreach ($readyToParseArray['data'] as $key => $property) {
230 - $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batchKey . ' with ListingKey: ' . $property['ListingKey'] . PHP_EOL;
231 - $this->writeImportLogs($logs, 'cron');
232 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'cron', $mlsimportItemOptionData);
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;
233 437 }
234 - }
235 438
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 );
236 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 + }
237 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']);
238 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);
239 470
471 + // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
472 + update_option('mlsimport_token_expiry', intval($data['expires']));
240 473
474 + // First successful SaaS account connection (lifecycle telemetry).
475 + mlsimport_telemetry_set_once( 'account_connected_at', time() );
241 476
477 + // Refresh finished — release the single-flight lock.
478 + delete_option( 'mlsimport_token_refresh_lock' );
242 479
243 - /**
244 - * Check if property already imported
245 - *
246 - * @param string $key The key to search for.
247 - * @param string $postType The post type to search within (default is 'estate_property').
248 - * @return int The post ID if found, or 0 if not found.
249 - */
250 - public function mlsimportSaasRetrievePropertyById($key, $postType = 'estate_property') {
251 - $args = [
252 - 'post_type' => $postType,
253 - 'post_status' => 'any',
254 - 'meta_query' => [
255 - [
256 - 'key' => 'ListingKey',
257 - 'value' => $key,
258 - 'compare' => '=',
259 - ],
260 - ],
261 - 'fields' => 'ids',
262 - ];
480 + // A minted token proves the account works → back to healthy.
481 + self::setConnectionHealth( 'healthy' );
263 482
264 - $query = new WP_Query($args);
265 - if ($query->have_posts()) {
266 - $query->the_post();
267 - $propertyId = get_the_ID();
268 - wp_reset_postdata();
269 - return $propertyId;
270 - } else {
271 - wp_reset_postdata();
272 - return 0;
273 - }
483 + return true;
274 484 }
275 485
276 486
277 487
278 488
279 - /**
280 - * Clear taxonomy
281 - *
282 - * @param int $propertyId The property ID.
283 - * @param array $taxonomies The taxonomies to clear.
284 - */
285 - public function mlsimportSaasClearPropertyForTaxonomy($propertyId, $taxonomies) {
286 - if (is_array($taxonomies)) {
287 - foreach ($taxonomies as $taxonomy => $term) {
288 - if (is_wp_error($taxonomy)) {
289 - error_log('Error with taxonomy: ' . $taxonomy->get_error_message());
290 - continue; // Skip this iteration
291 - }
292 -
293 - if (taxonomy_exists($taxonomy)) {
294 - wp_delete_object_term_relationships($propertyId, $taxonomy);
295 - } else {
296 - error_log("Taxonomy does not exist: {$taxonomy}");
297 - }
298 - }
299 - }
300 - }
301 489
302 490
303 491
304 492
493 +
305 494
495 +
306 496 /**
307 - * Set taxonomy for property
497 + * Write logs for import process
308 498 *
309 - * @param string $taxonomy The taxonomy to set.
310 - * @param int $propertyId The property ID.
311 - * @param mixed $fieldValues The values to set.
499 + * @param string $logs The log message to write.
500 + * @param string $type The type of log.
312 501 */
313 - public function mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $fieldValues) {
314 - global $wpdb;
502 + private function writeImportLogs($logs, $type) {
503 + mlsimport_saas_single_write_import_custom_logs($logs, $type);
504 + }
315 505
316 - // Convert comma-separated values to array if necessary
317 - if (!is_array($fieldValues)) {
318 - $fieldValues = strpos($fieldValues, ',') !== false ? explode(',', $fieldValues) : [$fieldValues];
319 - }
320 506
321 - // Trim values and remove empty ones
322 - $fieldValues = array_filter(array_map('trim', $fieldValues));
323 507
324 - // Start a database transaction
325 - $wpdb->query('START TRANSACTION');
326 - $taxLog = [];
327 508
328 - foreach (array_chunk($fieldValues, 5) as $chunk) {
329 - foreach ($chunk as $value) {
330 - if (!empty($value)) {
331 - // Check if the term already exists
332 - $term = $wpdb->get_row($wpdb->prepare(
333 - "SELECT t.*, tt.* FROM $wpdb->terms t
334 - INNER JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id
335 - WHERE t.name = %s AND tt.taxonomy = %s",
336 - $value, $taxonomy
337 - ));
509 +
338 510
339 - $taxLog[] = json_encode($term);
340 - if (is_null($term)) {
341 - // Insert the term if it doesn't exist
342 - $wpdb->insert($wpdb->terms, [
343 - 'name' => $value,
344 - 'slug' => sanitize_title($value),
345 - 'term_group' => 0
346 - ]);
347 511
348 - $termId = $wpdb->insert_id;
349 512
350 - if ($termId) {
351 - // Insert term taxonomy
352 - $wpdb->insert($wpdb->term_taxonomy, [
353 - 'term_id' => $termId,
354 - 'taxonomy' => $taxonomy,
355 - 'description' => '',
356 - 'parent' => 0,
357 - 'count' => 0
358 - ]);
359 513
360 - $termTaxonomyId = $wpdb->insert_id;
361 - } else {
362 - $taxLog[] = 'Error inserting term';
363 - continue;
364 - }
365 - } else {
366 - // Term exists, get term_id and term_taxonomy_id
367 - $termId = $term->term_id;
368 - $termTaxonomyId = $wpdb->get_var($wpdb->prepare(
369 - "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s",
370 - $termId, $taxonomy
371 - ));
372 - }
373 514
374 - if (!empty($termTaxonomyId)) {
375 - // Insert term relationship
376 - $wpdb->replace($wpdb->term_relationships, [
377 - 'object_id' => $propertyId,
378 - 'term_taxonomy_id' => $termTaxonomyId
379 - ]);
380 - // Increment the term count
381 - $wpdb->query($wpdb->prepare(
382 - "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d",
383 - $termTaxonomyId
384 - ));
385 - } else {
386 - $taxLog[] = 'Error: term_taxonomy_id is null';
387 - }
388 - }
389 - }
390 - // Flush the cache to free up memory
391 - wp_cache_flush();
392 - // Run garbage collection
393 - gc_collect_cycles();
394 - }
395 - // Commit the transaction
396 - $wpdb->query('COMMIT');
397 515
398 - // Clear term cache selectively
399 - wp_cache_delete("{$taxonomy}_terms", 'terms');
400 - wp_cache_delete("{$taxonomy}_children", 'terms');
401 -
402 - // Restore the term metadata filter
403 - add_filter('get_term_metadata', [$wpdb->terms, 'cache_term_counts'], 10, 2);
404 516
405 - // Log memory usage
406 - // if (!empty($taxLog)) {
407 - // $taxLogStr = implode(PHP_EOL, $taxLog);
408 - // mlsimport_saas_single_write_import_custom_logs($taxLogStr, 'normal');
409 - // unset($taxLogStr);
410 - // }
411 - }
412 517
413 518
414 519
415 520
416 - /**
417 - * Set Property Title
418 - *
419 - * @param int $propertyId The property ID.
420 - * @param int $mlsImportPostId The MLS import post ID.
421 - * @param array $property The property data.
422 - * @return string The updated title format.
423 - */
424 - public function mlsimportSaasUpdatePropertyTitle($propertyId, $mlsImportPostId, $property) {
425 - global $mlsimport;
426 521
427 - $titleFormat = esc_html(get_post_meta($mlsImportPostId, 'mlsimport_item_title_format', true));
428 522
429 - if ('' === $titleFormat) {
430 - $options = get_option('mlsimport_admin_mls_sync');
431 - $titleFormat = $options['title_format'];
432 - }
433 523
434 - $titleArray = $this->strBetweenAll($titleFormat, '{', '}');
435 524
436 - $propertyExtraMetaArrayLowerCase = array_change_key_case($property['extra_meta'], CASE_LOWER);
437 525
438 - foreach ($titleArray as $key => $value) {
439 - $replace = '';
440 - switch ($value) {
441 - case 'Address':
442 - $replace = $property['adr_title'] ?? '';
443 - break;
444 - case 'City':
445 - $replace = $property['adr_city'] ?? '';
446 - break;
447 - case 'CountyOrParish':
448 - $replace = $property['adr_county'] ?? '';
449 - break;
450 - case 'PropertyType':
451 - $replace = $property['adr_type'] ?? '';
452 - break;
453 - case 'Bedrooms':
454 - $replace = $property['adr_bedrooms'] ?? '';
455 - break;
456 - case 'Bathrooms':
457 - $replace = $property['adr_bathrooms'] ?? '';
458 - break;
459 - case 'ListingKey':
460 - $replace = $property['ListingKey'];
461 - break;
462 - case 'ListingId':
463 - $replace = $property['adr_listingid'] ?? '';
464 - break;
465 - case 'StateOrProvince':
466 - $replace = $property['extra_meta']['StateOrProvince'] ?? '';
467 - break;
468 - case 'PostalCode':
469 - $replace = $property['meta']['property_zip'] ?? $property['meta']['fave_property_zip'] ?? '';
470 - $replace = is_array($replace) ? strval($replace[0]) : strval($replace);
471 - break;
472 - case 'StreetNumberNumeric':
473 - $replace = $propertyExtraMetaArrayLowerCase['streetnumbernumeric'] ?? '';
474 - break;
475 - case 'StreetName':
476 - $replace = $propertyExtraMetaArrayLowerCase['streetname'] ?? '';
477 - break;
478 - }
479 - $titleFormat = str_replace('{' . $value . '}', $replace, $titleFormat);
480 - }
481 526
482 - $post = [
483 - 'ID' => $propertyId,
484 - 'post_title' => $titleFormat,
485 - 'post_name' => $titleFormat,
486 - ];
487 527
488 - wp_update_post($post);
489 528
490 - return $titleFormat;
491 - }
492 529
493 -
494 530
495 531
496 532
497 533
498 - /**
499 - * Prepare meta data for property
500 - *
501 - * @param array $property The property data.
502 - * @return array The property data with prepared meta.
503 - */
504 - public function mlsimportSaasPrepareMetaForProperty($property) {
505 - if (isset($property['extra_meta']['BathroomsTotalDecimal']) && floatval($property['extra_meta']['BathroomsTotalDecimal']) > 0) {
506 - $bathrooms = floatval($property['extra_meta']['BathroomsTotalDecimal']);
507 - $property['meta']['property_bathrooms'] = $bathrooms;
508 - $property['meta']['fave_property_bathrooms'] = $bathrooms;
509 - $property['meta']['REAL_HOMES_property_bathrooms'] = $bathrooms;
510 - }
511 - return $property;
512 - }
513 534
514 535
515 536
516 -
517 537
518 - /**
519 - * Attach media to post
520 - *
521 - * @param int $propertyId The property ID.
522 - * @param array $media The media data.
523 - * @param string $isInsert Whether the property is being inserted.
524 - * @return string The media history log.
525 - */
526 - public function mlsimportSassAttachMediaToPost($propertyId, $media, $isInsert) {
527 - $mediaHistory = [];
528 - if ($isInsert === 'no') {
529 - $mediaHistory[] = 'Media - We have edit - images are not replaced';
530 - return implode('</br>', $mediaHistory);
531 - }
532 538
533 - global $mlsimport;
534 - include_once ABSPATH . 'wp-admin/includes/image.php';
535 - $hasFeatured = false;
536 539
537 - delete_post_meta($propertyId, 'fave_property_images');
538 - delete_post_meta($propertyId, 'REAL_HOMES_property_images');
539 540
540 - add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
541 541
542 - // Sorting media
543 - if (isset($media[0]['Order'])) {
544 - $order = array_column($media, 'Order');
545 - array_multisort($order, SORT_ASC, $media);
546 - }
547 542
548 - if (is_array($media)) {
549 - foreach ($media as $image) {
550 - if (isset($image['MediaCategory']) && $image['MediaCategory'] !== 'Photo') {
551 - continue;
552 - }
553 543
554 - $file = $image['MediaURL'];
555 544
556 - if (isset($image['MediaURL'])) {
557 - $attachment = [
558 - 'guid' => $image['MediaURL'],
559 - 'post_status' => 'inherit',
560 - 'post_content' => '',
561 - 'post_parent' => $propertyId,
562 - 'post_mime_type' => $image['MimeType'] ?? 'image/jpg',
563 - 'post_title' => $image['MediaKey'] ?? '',
564 - ];
565 545
566 - $attachId = wp_insert_attachment($attachment, $file);
546 +
567 547
568 - $mediaHistory[] = 'Media - Added ' . $image['MediaURL'] . ' as attachment ' . $attachId;
569 - $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
570 548
571 - update_post_meta($attachId, 'is_mlsimport', 1);
572 - if (!$hasFeatured) {
573 - set_post_thumbnail($propertyId, $attachId);
574 - $hasFeatured = true;
575 - }
576 - }
577 - }
578 - } else {
579 - $mediaHistory[] = 'Media data is blank - there are no images';
580 - }
581 549
582 - remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
583 550
584 - return implode('</br>', $mediaHistory);
585 - }
586 551
587 - /**
588 - * Unset image sizes
589 - *
590 - * @param array $sizes The sizes to unset.
591 - * @return array The modified sizes array.
592 - */
593 - public function wpcUnsetImageSizes($sizes) {
594 - return [];
595 - }
596 552
597 553
598 554
599 555
600 -
601 -
602 -
603 556 /**
604 557 * Return user option
605 558 *
606 559 * @param int $selected The selected user ID.
@@ -607,11 +560,13 @@
607 560 * @return string The HTML option elements for users.
608 561 */
609 562 public function mlsimportSaasThemeImportSelectUser($selected) {
610 563 $userOptions = '';
564 + // Fetch all users to build a <select> of possible property authors.
611 565 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
612 566 foreach ($blogusers as $user) {
613 567 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
568 + // Pre-select the currently chosen user.
614 569 if ($user->ID == $selected) {
615 570 $userOptions .= ' selected="selected"';
616 571 }
617 572 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
@@ -632,8 +587,9 @@
632 587 * @return string The HTML option elements for agents.
633 588 */
634 589 public function mlsimportSaasThemeImportSelectAgent($selected) {
635 590 global $mlsimport;
591 + // Query up to 150 published agents of the theme's agent post type.
636 592 $args = [
637 593 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
638 594 'post_status' => 'publish',
639 595 'posts_per_page' => 150,
@@ -639,15 +595,18 @@
639 595 'posts_per_page' => 150,
640 596 ];
641 597
642 598 $agentSelection = new WP_Query($args);
599 + // Start with a blank option (no agent).
643 600 $agentOptions = '<option value=""></option>';
644 601
602 + // Build one <option> per agent post.
645 603 while ($agentSelection->have_posts()) {
646 604 $agentSelection->the_post();
647 605 $agentId = get_the_ID();
648 606
649 607 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
608 + // Pre-select the currently chosen agent.
650 609 if ($agentId == $selected) {
651 610 $agentOptions .= ' selected="selected"';
652 611 }
653 612 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
@@ -663,461 +622,326 @@
663 622
664 623
665 624
666 625
667 - /**
668 - * Delete property
669 - *
670 - * @param int $deleteId The ID of the property to delete.
671 - * @param string $ListingKey The listing key of the property.
672 - */
673 - public function deleteProperty($deleteId, $ListingKey) {
674 - if ($deleteId > 0) {
675 - $args = [
676 - 'numberposts' => -1,
677 - 'post_type' => 'attachment',
678 - 'post_parent' => $deleteId,
679 - 'post_status' => null,
680 - 'orderby' => 'menu_order',
681 - 'order' => 'ASC',
682 - ];
683 - $postAttachments = get_posts($args);
684 626
685 - foreach ($postAttachments as $attachment) {
686 - wp_delete_post($attachment->ID);
687 - }
688 627
689 - wp_delete_post($deleteId);
690 - $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
691 - $this->writeImportLogs($logEntry, 'delete');
692 - }
693 - }
694 628
695 629
696 630
697 631
698 - /**
699 - * Return array with title items
700 - *
701 - * @param string $string The input string.
702 - * @param string $start The start delimiter.
703 - * @param string $end The end delimiter.
704 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
705 - * @param int $offset The offset to start searching from.
706 - * @return array The array of strings found between the delimiters.
707 - */
708 - public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
709 - $strings = [];
710 - $length = strlen($string);
711 632
712 - while ($offset < $length) {
713 - $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
714 - if ($found === null) {
715 - break;
716 - }
717 633
718 - $strings[] = $found;
719 - $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
720 - }
721 634
722 - return $strings;
723 - }
724 635
725 636 /**
726 - * Find string between delimiters
727 - *
728 - * @param string $string The input string.
729 - * @param string $start The start delimiter.
730 - * @param string $end The end delimiter.
731 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
732 - * @param int $offset The offset to start searching from.
733 - * @return string|null The string found between the delimiters, or null if not found.
734 - */
735 - public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
736 - if ($string === '' || $start === '' || $end === '') {
737 - return null;
738 - }
739 -
740 - $startLength = strlen($start);
741 - $endLength = strlen($end);
742 -
743 - $startPos = strpos($string, $start, $offset);
744 - if ($startPos === false) {
745 - return null;
746 - }
747 -
748 - $endPos = strpos($string, $end, $startPos + $startLength);
749 - if ($endPos === false) {
750 - return null;
751 - }
752 -
753 - $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
754 - if (!$length) {
755 - return '';
756 - }
757 -
758 - $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
759 -
760 - return substr($string, $offset, $length);
761 - }
762 -
763 -
764 -
765 -
766 -
767 - /**
768 637 * Delete property via SQL
769 638 *
770 639 * @param int $deleteId The ID of the property to delete.
771 640 * @param string $ListingKey The listing key of the property.
772 641 */
773 - public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
774 - $postType = get_post_type($deleteId);
642 + public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
643 + global $mlsimport;
775 644
776 - if (in_array($postType, ['estate_property', 'property'])) {
777 - $termObjList = get_the_terms($deleteId, 'property_status');
778 - $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
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 + }
779 651
780 - $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
781 - if ('' === $ListingKey) { // manually added listing
782 - $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
783 - $this->writeImportLogs($logEntry, 'delete');
784 - return;
785 - }
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 + ]);
786 665
787 - global $wpdb;
788 - $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
789 - $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
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')) : '';
790 669
791 - $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
792 - $this->writeImportLogs($logEntry, 'delete');
793 - }
794 - }
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 + }
795 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));
796 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);
797 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));
798 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 + }
799 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 + }
800 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 + }
801 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 );
802 731
803 - /**
804 - * Prepare to import per item
805 - *
806 - * @param array $property The property data.
807 - * @param array $itemIdArray The item ID array.
808 - * @param string $tipImport The import type.
809 - * @param array $mlsimportItemOptionData The item option data.
810 - */
811 - public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
812 - set_time_limit(0);
813 - global $mlsimport;
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 + }
814 736
815 - $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
816 - $mlsImportItemStatusDelete = $mlsimportItemOptionData['mlsimport_item_standardstatusdelete'];
817 - $newAuthor = $mlsimportItemOptionData['mlsimport_item_property_user'];
818 - $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
819 - $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
820 737
821 - if (is_array($mlsImportItemStatus)) {
822 - $mlsImportItemStatus = array_map('strtolower', $mlsImportItemStatus);
823 - }
824 738
825 - if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
826 - $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
827 - return;
828 - }
829 739
830 - ob_start();
831 740
832 - $ListingKey = $property['ListingKey'];
833 - $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
834 - $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
835 - $status = isset($property['StandardStatus']) ? strtolower($property['StandardStatus']) : strtolower($property['extra_meta']['MlsStatus']);
836 -
837 -
838 - $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
839 741
840 742
841 - $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
842 743
843 - $log = $this->mlsimportMemUsage() . '==========' . wp_json_encode($mlsImportItemStatus) . '/' . $newAuthor . '/' . $newAgent . '/' . $propertyStatus . '/ We have property with $ListingKey=' . $ListingKey . ' id=' . $propertyId . ' with status ' . $status . ' is insert? ' . $isInsert . PHP_EOL;
844 - $this->writeImportLogs($log, $tipImport);
845 744
846 - $propertyHistory = [];
847 - $content = $property['content'] ?? '';
848 - $submitTitle = $ListingKey;
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;
770 + }
849 771
850 - if ($isInsert === 'yes') {
851 - $post = [
852 - 'post_title' => $submitTitle,
853 - 'post_content' => $content,
854 - 'post_status' => $propertyStatus,
855 - 'post_type' => $listingPostType,
856 - 'post_author' => $newAuthor,
857 - ];
858 -
859 - $propertyId = wp_insert_post($post);
860 - if (is_wp_error($propertyId)) {
861 - $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
862 - } else {
863 - update_post_meta($propertyId, 'ListingKey', $ListingKey);
864 - update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
865 - update_post_meta($propertyId, 'mlsImportItemStatusDelete', $mlsImportItemStatusDelete);
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 + );
866 794
867 -
868 -
869 - $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId.'. The delete statuses are '.$keep_on_delete;
870 - }
795 + return $this->stored_listing_write->write( $property, $settings );
796 +}
871 797
872 - clean_post_cache( $propertyId );
873 798
874 - } elseif ($propertyId !== 0) {
799 +
800 +
801 +
875 802
876 - $keep = $this->check_if_delete_when_status($property_id);
877 803
878 - if(!$keep){
879 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . ' / '.$post_status.'</strong> and will be deleted' . PHP_EOL;
880 - $this->deleteProperty($propertyId, $ListingKey);
881 - $this->writeImportLogs($log, $tipImport);
882 - }else{
883 - update_post_meta($propertyId, 'mlsImportItemStatusDelete', $mlsImportItemStatusDelete);
884 - $propertyHistory = $this->updateExistingProperty($propertyId,$mlsImportItemStatusDelete, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
885 - }
886 - }
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) {
887 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);
888 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 + }
889 835
836 + // Default: delete if not protected
837 + return false;
838 +}
890 839
891 840
892 841
893 - if ($propertyId === 0) {
894 - $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
895 - return;
896 - }
897 842
898 - $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
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);
899 856
900 - $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
901 - $this->writeImportLogs($log, $tipImport);
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);
902 862
903 - clean_post_cache( $propertyId );
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 + }
904 868
905 - ob_end_clean();
906 - }
907 -
908 -
909 -
910 -
911 - /**
912 - * Check if the property should be inserted
913 - *
914 - * @param int $propertyId The property ID.
915 - * @param string $status The property status.
916 - * @param array $mlsImportItemStatus The MLS import item status.
917 - * @param string $tipImport The import type.
918 - * @return string 'yes' or 'no' indicating if the property should be inserted.
919 - */
920 - private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
921 - $this->writeImportLogs(
922 - "Checking: on inserting {$propertyId}={$status} vs " .
923 - json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
924 - $tipImport
925 - );
926 -
927 - if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
928 - return 'no';
929 - }
930 -
931 - $activeStatuses = [
932 - 'active',
933 - 'active under contract',
934 - 'active with contract',
935 - 'activewithcontract',
936 - 'status',
937 - 'activeundercontract',
938 - 'comingsoon',
939 - 'coming soon',
940 - 'pending'
941 - ];
942 - if(is_array($mlsImportItemStatus)){
943 - if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
944 - return 'no';
945 - }
946 -
947 - if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
948 - return 'no';
949 - }
950 -
951 - }else{
952 - if(!in_array($status, $activeStatuses, true) ){
953 - return 'no';
954 - }
955 - }
956 -
957 - return 'yes';
958 - }
959 -
960 -
961 - /**
962 - * Check for property status agains mls item delete status to see if we keep or delete the listing
963 - *
964 - *
965 - */
966 -
967 - public function check_if_delete_when_status($property_id){
968 - $delete_status= get_post_meta( $property_id, 'mlsImportItemStatusDelete', true );
969 -
970 -
971 - if (taxonomy_exists('property_status')) {
972 - // wpresidence
973 - $termObjList = get_the_terms($property_id, 'property_status');
974 - }else if(taxonomy_exists('property_label')){
975 - // houzez
976 - $termObjList = get_the_terms($property_id, 'property_label');
977 - } else {
978 - //real homes
979 - $termObjList = get_post_meta( $property_id, 'inspiry_property_label', true );
980 - }
981 -
982 - // property_label // houzez
983 - // inspiry_property_label
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)) {
984 872
985 - if(isset($termObjList) && is_array($termObjList)){
986 - $post_status = $termObjList[0]->name;
987 - }
873 + return true;
874 + }
988 875
989 876
990 - $keep=true;
991 - if(!empty($delete_status)){
992 -
993 - if(is_array($delete_status) && in_array($post_status, $delete_status)){
994 - $keep=false;
995 -
996 - }else if($post_status==$delete_status){
997 - $keep=false;
998 - }
999 877
1000 - }
878 + // Default: status read but doesn't match the task's selection → delete.
879 + return false;
880 +}
1001 881
1002 - print wp_kses_post('</br></br>' .$property_id. ' ------------------------- Kept: '.$property_id.' /'.$post_status.'<-');
1003 - var_dump($delete_status);
1004 - print '</br><br>';
1005 882
1006 - return $keep;
1007 - }
1008 883
1009 884
1010 885
1011 886
887 +
1012 888 /**
1013 - * Update existing property
1014 - *
1015 - * @param int $propertyId The property ID.
1016 - * @param string $content The post content.
1017 - * @param string $listingPostType The listing post type.
1018 - * @param int $newAuthor The new author ID.
1019 - * @param string $status The property status.
1020 - * @param array $mlsImportItemStatus The MLS import item status.
1021 - * @param array $propertyHistory The property history.
1022 - * @param string $tipImport The import type.
1023 - * @param string $ListingKey The listing key.
1024 - * @return array Updated property history.
1025 - */
1026 - private function updateExistingProperty($propertyId,$mlsImportItemStatusDelete, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1027 -
1028 -
1029 - $post = [
1030 - 'ID' => $propertyId,
1031 - 'post_content' => $content,
1032 - 'post_type' => $listingPostType,
1033 - 'post_author' => $newAuthor,
1034 - ];
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);
1035 898
1036 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1037 - $this->writeImportLogs($log, $tipImport);
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 + }
1038 906
1039 - $propertyId = wp_update_post($post);
1040 - if (is_wp_error($propertyId)) {
1041 - $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1042 - } else {
1043 - $submitTitle = get_the_title($propertyId);
1044 - $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1045 - }
1046 - clean_post_cache( $propertyId );
1047 -
1048 - return $propertyHistory;
1049 - }
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 + }
1050 918
1051 - /**
1052 - * Process property details
1053 - *
1054 - * @param array $property The property data.
1055 - * @param int $propertyId The property ID.
1056 - * @param string $tipImport The import type.
1057 - * @param array $propertyHistory The property history.
1058 - * @param int $newAgent The new agent ID.
1059 - * @param array $itemIdArray The item ID array.
1060 - * @param string $isInsert If is a property insert
1061 - */
1062 - private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1063 - global $mlsimport;
1064 - $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1065 - $this->writeImportLogs($log, $tipImport);
919 + // Early return if MLS status empty
920 + if (empty($mlsimport_item_standardstatus)) {
921 + return true; // default: keep if no status set
922 + }
1066 923
1067 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1068 - remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1069 - wp_cache_delete('get_ancestors', 'taxonomy');
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 + }
1070 933
1071 - $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1072 934
1073 - foreach ($property['taxonomies'] as $taxonomy => $term) {
1074 - wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1075 - $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1076 - $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1077 - }
1078 935
1079 - add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1080 - delete_option('category_children');
1081 - }
1082 936
1083 - wp_cache_flush();
1084 937
1085 - $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1086 938
1087 - if (isset($property['meta']) && is_array($property['meta'])) {
1088 - foreach ($property['meta'] as $metaName => $metaValue) {
1089 - if (is_array($metaValue)) {
1090 - $metaValue = implode(',', $metaValue);
1091 - }
1092 - update_post_meta($propertyId, $metaName, $metaValue);
1093 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1094 - }
1095 - }
1096 939
1097 - $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1098 - if (isset($extraMetaResult['property_history'])) {
1099 - $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1100 - }
1101 940
1102 - $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'], $isInsert);
1103 - $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
1104 941
1105 - $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
1106 - $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
1107 942
1108 - $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
1109 943
1110 - if (!empty($propertyHistory)) {
1111 - if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
1112 - $propertyHistory[] = '---------------------------------------------------------------</br>';
1113 - $propertyHistory = implode('</br>', $propertyHistory);
1114 - update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
1115 - }
1116 - }
1117 -
1118 - return $newTitle;
1119 - }
1120 944
1121 945
1122 946
1123 947