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 +586 -686 5.8.37.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,136 +622,121 @@
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
637 + * Delete property via SQL
727 638 *
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.
639 + * @param int $deleteId The ID of the property to delete.
640 + * @param string $ListingKey The listing key of the property.
734 641 */
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 - }
642 + public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
643 + global $mlsimport;
739 644
740 - $startLength = strlen($start);
741 - $endLength = strlen($end);
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 + }
742 651
743 - $startPos = strpos($string, $start, $offset);
744 - if ($startPos === false) {
745 - return null;
746 - }
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 + ]);
747 665
748 - $endPos = strpos($string, $end, $startPos + $startLength);
749 - if ($endPos === false) {
750 - return null;
751 - }
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')) : '';
752 669
753 - $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
754 - if (!$length) {
755 - return '';
756 - }
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 + }
757 679
758 - $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
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));
759 683
760 - return substr($string, $offset, $length);
761 - }
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);
762 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));
763 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 + }
764 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 + }
765 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 + }
766 727
767 - /**
768 - * Delete property via SQL
769 - *
770 - * @param int $deleteId The ID of the property to delete.
771 - * @param string $ListingKey The listing key of the property.
772 - */
773 - public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
774 - $postType = get_post_type($deleteId);
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 );
775 731
776 - if (in_array($postType, ['estate_property', 'property'])) {
777 - $termObjList = get_the_terms($deleteId, 'property_status');
778 - $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
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 + }
779 736
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 - }
786 737
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));
790 738
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 - }
795 739
796 740
797 741
798 742
@@ -797,251 +741,207 @@
797 741
798 742
799 743
800 744
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 + }
801 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 + );
802 794
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;
795 + return $this->stored_listing_write->write( $property, $settings );
796 +}
814 797
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 798
821 - if (is_array($mlsImportItemStatus)) {
822 - $mlsImportItemStatus = array_map('strtolower', $mlsImportItemStatus);
823 - }
799 +
800 +
801 +
824 802
825 - if (!isset($property['ListingKey'])) {
826 - $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
827 - return;
828 - }
829 803
830 - ob_start();
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) {
831 812
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 - $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
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);
837 818
838 - $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;
839 - $this->writeImportLogs($log, $tipImport);
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 + }
840 835
841 - $propertyHistory = [];
842 - $content = $property['content'] ?? '';
843 - $submitTitle = $ListingKey;
836 + // Default: delete if not protected
837 + return false;
838 +}
844 839
845 - if ($isInsert === 'yes') {
846 - $post = [
847 - 'post_title' => $submitTitle,
848 - 'post_content' => $content,
849 - 'post_status' => $propertyStatus,
850 - 'post_type' => $listingPostType,
851 - 'post_author' => $newAuthor,
852 - ];
853 840
854 - $propertyId = wp_insert_post($post);
855 - if (is_wp_error($propertyId)) {
856 - $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
857 - } else {
858 - update_post_meta($propertyId, 'ListingKey', $ListingKey);
859 - $keep_on_delete='delete';
860 - if( is_array($mlsImportItemStatusDelete) && !in_array($status,$mlsImportItemStatusDelete)){
861 - $keep_on_delete='keep';
862 - update_post_meta($propertyId, 'mlsImportItemStatusDelete', $keep_on_delete);
863 - }
864 841
865 842
866 -
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 - }
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);
871 856
872 - clean_post_cache( $propertyId );
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);
873 862
874 - } elseif ($propertyId !== 0) {
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 + }
875 868
876 - $keep_on_delete='delete';
877 - if(is_array($mlsImportItemStatusDelete) && !in_array($status,$mlsImportItemStatusDelete)){
878 - $keep_on_delete='keep';
879 - update_post_meta($propertyId, 'mlsImportItemStatusDelete', $keep_on_delete);
880 - }
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 + }
881 875
882 - $propertyHistory = $this->updateExistingProperty($propertyId,$mlsImportItemStatusDelete, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
883 - }
884 876
885 - if ($propertyId === 0) {
886 - $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
887 - return;
888 - }
889 877
890 - $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
878 + // Default: status read but doesn't match the task's selection → delete.
879 + return false;
880 +}
891 881
892 - $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
893 - $this->writeImportLogs($log, $tipImport);
894 882
895 - clean_post_cache( $propertyId );
896 883
897 - ob_end_clean();
898 - }
899 884
900 885
901 886
902 -
887 +
903 888 /**
904 - * Check if the property should be inserted
905 - *
906 - * @param int $propertyId The property ID.
907 - * @param string $status The property status.
908 - * @param array $mlsImportItemStatus The MLS import item status.
909 - * @param string $tipImport The import type.
910 - * @return string 'yes' or 'no' indicating if the property should be inserted.
911 - */
912 - private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport) {
913 -
914 - if ($propertyId === 0) {
915 - if (in_array($status, ['active', 'active under contract', 'active with contract', 'activewithcontract', 'status', 'activeundercontract', 'comingsoon', 'coming soon', 'pending'])) {
916 - if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus)) {
917 - return 'no';
918 - }
919 - return 'yes';
920 - }
921 - return 'no';
922 - }
923 - return 'no';
924 -
925 - }
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);
926 898
927 - /**
928 - * Update existing property
929 - *
930 - * @param int $propertyId The property ID.
931 - * @param string $content The post content.
932 - * @param string $listingPostType The listing post type.
933 - * @param int $newAuthor The new author ID.
934 - * @param string $status The property status.
935 - * @param array $mlsImportItemStatus The MLS import item status.
936 - * @param array $propertyHistory The property history.
937 - * @param string $tipImport The import type.
938 - * @param string $ListingKey The listing key.
939 - * @return array Updated property history.
940 - */
941 - private function updateExistingProperty($propertyId,$mlsImportItemStatusDelete, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
942 -
943 - if (is_array($mlsImportItemStatusDelete)) {
944 - $mlsImportItemStatusDelete = array_map('strtolower', $mlsImportItemStatusDelete);
945 - }
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 + }
946 906
947 - if (is_array($mlsImportItemStatusDelete) && in_array($status, $mlsImportItemStatusDelete)) {
948 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be deleted' . PHP_EOL;
949 - $this->deleteProperty($propertyId, $ListingKey);
950 - $this->writeImportLogs($log, $tipImport);
951 - } else {
952 - $post = [
953 - 'ID' => $propertyId,
954 - 'post_content' => $content,
955 - 'post_type' => $listingPostType,
956 - 'post_author' => $newAuthor,
957 - ];
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 + }
958 918
959 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
960 - $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 + }
961 923
962 - $propertyId = wp_update_post($post);
963 - if (is_wp_error($propertyId)) {
964 - $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
965 - } else {
966 - $submitTitle = get_the_title($propertyId);
967 - $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
968 - }
969 - clean_post_cache( $propertyId );
970 - }
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 + }
971 933
972 - return $propertyHistory;
973 - }
974 934
975 - /**
976 - * Process property details
977 - *
978 - * @param array $property The property data.
979 - * @param int $propertyId The property ID.
980 - * @param string $tipImport The import type.
981 - * @param array $propertyHistory The property history.
982 - * @param int $newAgent The new agent ID.
983 - * @param array $itemIdArray The item ID array.
984 - * @param string $isInsert If is a property insert
985 - */
986 - private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
987 - global $mlsimport;
988 - $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
989 - $this->writeImportLogs($log, $tipImport);
990 935
991 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
992 - remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
993 - wp_cache_delete('get_ancestors', 'taxonomy');
994 936
995 - $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
996 937
997 - foreach ($property['taxonomies'] as $taxonomy => $term) {
998 - wp_cache_delete("{$taxonomy}_term_counts", 'counts');
999 - $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1000 - $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1001 - }
1002 938
1003 - add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1004 - delete_option('category_children');
1005 - }
1006 939
1007 - wp_cache_flush();
1008 940
1009 - $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1010 941
1011 - if (isset($property['meta']) && is_array($property['meta'])) {
1012 - foreach ($property['meta'] as $metaName => $metaValue) {
1013 - if (is_array($metaValue)) {
1014 - $metaValue = implode(',', $metaValue);
1015 - }
1016 - update_post_meta($propertyId, $metaName, $metaValue);
1017 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1018 - }
1019 - }
1020 942
1021 - $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1022 - if (isset($extraMetaResult['property_history'])) {
1023 - $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1024 - }
1025 943
1026 - $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'], $isInsert);
1027 - $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
1028 -
1029 - $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
1030 - $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
1031 -
1032 - $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
1033 -
1034 - if (!empty($propertyHistory)) {
1035 - if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
1036 - $propertyHistory[] = '---------------------------------------------------------------</br>';
1037 - $propertyHistory = implode('</br>', $propertyHistory);
1038 - update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
1039 - }
1040 - }
1041 -
1042 - return $newTitle;
1043 - }
1044 944
1045 945
1046 946
1047 947