PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.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
mlsimport / includes / ThemeImport.php

ThemeImport.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.2, at includes/ThemeImport.php

916 lines 32.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 */
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit; // Exit if accessed directly
18 }
19
20 /**
21 * Expose legacy API/batch methods around the explicit listing-write module.
22 */
23 class ThemeImport {
24
25
26 // Active theme adapter / identifier (set by callers).
27 public $theme;
28 // Plugin slug/name carried for logging and context.
29 public $plugin_name;
30 // Environment adapter instance (theme-specific meta mapping).
31 public $enviroment;
32 // Cached encoded credential/config values.
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 }
52
53
54 /**
55 * Api Request to MLSimport API using CURL
56 *
57 * @param string $method The API method to call.
58 * @param array $values_array The values to pass to the API.
59 * @param string $type The request type (default is 'GET').
60 * @return mixed The API response or error message.
61 */
62
63 public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') {
64
65
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.
78 $url = MLSIMPORT_API_URL . $method;
79 // Default headers for the token request (plain text body).
80 $headers = ['Content-Type' => 'text/plain'];
81
82 // For authenticated calls, swap to JSON + Bearer token headers.
83 if ($method !== 'token') {
84 $token = self::getApiToken();
85 $headers = [
86 'Content-Type' => 'application/json',
87 'Authorization' => 'Bearer '.$token,
88 ];
89 }
90
91 // Assemble the wp_remote_* argument array (long timeout for large payloads).
92 $args = [
93 'method' => $type,
94 'headers' => $headers,
95 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
96 'timeout' => 120,
97 'redirection' => 10,
98 'httpversion' => '1.1',
99 'blocking' => true,
100 'user-agent' => $_SERVER['HTTP_USER_AGENT'],
101 ];
102
103
104 // Dispatch as GET or POST depending on $type.
105 $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
106
107 // #208 recovery: same rule as globalApiRequestSaas() — one refresh and
108 // one retry when the server rejects the Bearer token mid-flight.
109 if ( 'token' !== $method
110 && ! is_wp_error( $response )
111 && 401 === intval( $response['response']['code'] ?? 0 )
112 && self::refreshToken() ) {
113 $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken();
114 $response = $type === 'GET' ? wp_remote_get( $url, $args ) : wp_remote_post( $url, $args );
115 }
116
117 // Transport-level failure: return the WP_Error message string.
118 if (is_wp_error($response)) {
119 return $response->get_error_message();
120 } else {
121 // Otherwise decode the JSON body and return the array (or a decode-error string).
122 $body = wp_remote_retrieve_body($response);
123
124 $toReturn = json_decode($body, true);
125 if (json_last_error() !== JSON_ERROR_NONE) {
126 return 'JSON decode error: ' . json_last_error_msg();
127 }
128 return $toReturn;
129 }
130 }
131
132
133 /**
134 * Retrieve the API token
135 *
136 * @return string The API token.
137 */
138 private static function getApiToken() {
139 global $mlsimport;
140 return $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
141 }
142
143
144 /**
145 * Api Request to MLSimport API
146 *
147 * @param string $method The API method to call.
148 * @param array $valuesArray The values to pass to the API.
149 * @param string $type The request type (default is 'GET').
150 * @return array The API response data.
151 */
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 */
201 public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') {
202 global $mlsimport;
203 // Skip validation for token and mls requests
204 if ($method !== 'token' && $method !== 'mls') {
205 // Guarantee a valid token; otherwise return a failure descriptor.
206 if (!self::validateAndRefreshToken()) {
207 return [
208 'success' => false,
209 'error_message' => 'Token validation failed'
210 ];
211 }
212 }
213
214
215 // Full endpoint URL.
216 $url = MLSIMPORT_API_URL . $method;
217
218 // Attach Bearer auth headers for authenticated methods only.
219 $headers = [];
220 if ($method !== 'token' && $method !== 'mls') {
221 $token = self::getApiToken();
222 $headers = [
223 'Authorization' => 'Bearer '.$token,
224 'Content-Type' => 'application/json',
225 ];
226 }
227
228
229 // Request arguments (note: always dispatched via wp_remote_post below).
230 $args = [
231 'method' => $type,
232 'timeout' => 45,
233 'redirection' => 5,
234 'httpversion' => '1.0',
235 'blocking' => true,
236 'headers' => $headers,
237 'cookies' => [],
238 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
239 ];
240 // Always POST (even for logical GETs) — the SaaS expects a JSON body.
241 $response = wp_remote_post($url, $args);
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 }
255
256 // Transport error → structured failure with WP error code/message.
257 if (is_wp_error($response)) {
258 return [
259 'success' => false,
260 'error_code' => $response->get_error_code(),
261 'error_message' => esc_html($response->get_error_message())
262 ];
263 }
264
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);
268
269 // 200 → return the decoded payload untouched.
270 if (200 === $status_code) {
271 $receivedData = json_decode($body, true);
272 return $receivedData;
273 }
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;
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 }
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 ];
299
300 exit();
301 }
302
303
304
305 /**
306 * Check if token is expired and refresh if needed
307 * Call this before any external API request
308 *
309 * @return bool True if token is valid, false if refresh failed
310 */
311 private static function validateAndRefreshToken() {
312 global $mlsimport;
313
314 // Get stored expiry timestamp
315 $token_expiry = get_option('mlsimport_token_expiry', 0);
316 $current_time = time();
317
318 // Check if token is expired (now at/after the stored expiry).
319 if ($current_time >= $token_expiry) {
320 // Token expired, refresh it
321 $refresh_result = self::refreshToken();
322
323 // Propagate refresh failure to the caller.
324 if (!$refresh_result) {
325 return false;
326 }
327 }
328
329 // Token is present and not past expiry.
330 return true;
331 }
332
333 /**
334 * Record the SaaS connection-health state (#208).
335 *
336 * Stores array{status, since} in the mlsimport_connection_health option:
337 * 'healthy', 'credentials_invalid' (server rejected the stored account),
338 * or 'credentials_missing' (nothing configured). Transient failures such
339 * as network timeouts never call this, so a working state is not lost to
340 * a hiccup. Re-recording an unchanged status is skipped so 'since' keeps
341 * pointing at when the state actually began.
342 *
343 * @param string $status New health status keyword.
344 * @return void
345 */
346 private static function setConnectionHealth( $status ) {
347 $health = get_option( 'mlsimport_connection_health', array() );
348 if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) {
349 return;
350 }
351 update_option(
352 'mlsimport_connection_health',
353 array(
354 'status' => $status,
355 'since' => time(),
356 )
357 );
358
359 // #208: a state CHANGE is the incident boundary — broken credentials
360 // open the connection incident, a working refresh resolves it. The
361 // alerts module dedups, so this cannot spam the SaaS.
362 if ( 'healthy' === $status ) {
363 if ( function_exists( 'mlsimport_alert_resolve' ) ) {
364 mlsimport_alert_resolve( 'connection:credentials' );
365 }
366 } elseif ( function_exists( 'mlsimport_alert_open' ) ) {
367 mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) );
368 }
369 }
370
371 /**
372 * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
373 *
374 * Reads the stored username/password, POSTs them, and on success stores the
375 * token in a transient plus the expiry timestamp in an option. Bumps the
376 * 'token_failures' telemetry counter on every failure path.
377 *
378 * @return bool True on successful refresh, false otherwise.
379 */
380 private static function refreshToken() {
381 global $mlsimport;
382
383 // Get credentials for token request
384 $options = get_option('mlsimport_admin_options');
385 // Pull the SaaS account credentials out of the plugin options.
386 $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : '';
387 $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : '';
388
389 // No credentials configured → cannot refresh.
390 if (empty($username) || empty($password)) {
391 mlsimport_telemetry_bump( 'token_failures' );
392 self::setConnectionHealth( 'credentials_missing' );
393 return false;
394 }
395
396 // #208 single-flight: only one process may refresh at a time.
397 // add_option() is a plain INSERT, so a concurrent process loses the
398 // race and backs off without firing a second token request. A lock
399 // older than 60 seconds belongs to a crashed owner (the token request
400 // itself times out at 45) and is taken over instead.
401 if ( ! add_option( 'mlsimport_token_refresh_lock', time(), '', 'no' ) ) {
402 $lock_held_since = intval( get_option( 'mlsimport_token_refresh_lock', 0 ) );
403 if ( time() - $lock_held_since < 60 ) {
404 return false;
405 }
406 update_option( 'mlsimport_token_refresh_lock', time() );
407 }
408
409 // Prepare token request
410 $url = MLSIMPORT_API_URL . 'token';
411 $body = wp_json_encode(array(
412 'username' => $username,
413 'password' => $password
414 ));
415
416 $args = array(
417 'method' => 'POST',
418 'headers' => array(
419 'Content-Type' => 'application/json'
420 ),
421 'body' => $body,
422 'timeout' => 45
423 );
424
425 // Make token request
426 $response = wp_remote_post($url, $args);
427
428 // Transport failure → count and abort (lock released for the next try).
429 if (is_wp_error($response)) {
430 mlsimport_telemetry_bump( 'token_failures' );
431 delete_option( 'mlsimport_token_refresh_lock' );
432 return false;
433 }
434
435 // Decode the JSON token response.
436 $body = wp_remote_retrieve_body($response);
437 $data = json_decode($body, true);
438
439 // Reject any response missing success/token/expires.
440 if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
441 mlsimport_telemetry_bump( 'token_failures' );
442 delete_option( 'mlsimport_token_refresh_lock' );
443 // The server answered and said no → the credentials themselves are
444 // bad (terminal until the user fixes them). A malformed/partial
445 // body is a server hiccup instead and leaves health untouched.
446 if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) {
447 self::setConnectionHealth( 'credentials_invalid' );
448 }
449 return false;
450 }
451
452 // Store new token and expiry
453 //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
454
455 // Cache the token in a transient sized to its remaining lifetime.
456 $expires_in = $data['expires'] - time();
457 set_transient('mlsimport_saas_token', $data['token'], $expires_in);
458
459 // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
460 update_option('mlsimport_token_expiry', intval($data['expires']));
461
462 // First successful SaaS account connection (lifecycle telemetry).
463 mlsimport_telemetry_set_once( 'account_connected_at', time() );
464
465 // Refresh finished — release the single-flight lock.
466 delete_option( 'mlsimport_token_refresh_lock' );
467
468 // A minted token proves the account works → back to healthy.
469 self::setConnectionHealth( 'healthy' );
470
471 return true;
472 }
473
474
475
476
477
478
479
480
481
482
483
484 /**
485 * Write logs for import process
486 *
487 * @param string $logs The log message to write.
488 * @param string $type The type of log.
489 */
490 private function writeImportLogs($logs, $type) {
491 mlsimport_saas_single_write_import_custom_logs($logs, $type);
492 }
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544 /**
545 * Return user option
546 *
547 * @param int $selected The selected user ID.
548 * @return string The HTML option elements for users.
549 */
550 public function mlsimportSaasThemeImportSelectUser($selected) {
551 $userOptions = '';
552 // Fetch all users to build a <select> of possible property authors.
553 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
554 foreach ($blogusers as $user) {
555 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
556 // Pre-select the currently chosen user.
557 if ($user->ID == $selected) {
558 $userOptions .= ' selected="selected"';
559 }
560 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
561 }
562 return $userOptions;
563 }
564
565
566
567
568
569
570
571 /**
572 * Return agent option
573 *
574 * @param int $selected The selected agent ID.
575 * @return string The HTML option elements for agents.
576 */
577 public function mlsimportSaasThemeImportSelectAgent($selected) {
578 global $mlsimport;
579 // Query up to 150 published agents of the theme's agent post type.
580 $args = [
581 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
582 'post_status' => 'publish',
583 'posts_per_page' => 150,
584 ];
585
586 $agentSelection = new WP_Query($args);
587 // Start with a blank option (no agent).
588 $agentOptions = '<option value=""></option>';
589
590 // Build one <option> per agent post.
591 while ($agentSelection->have_posts()) {
592 $agentSelection->the_post();
593 $agentId = get_the_ID();
594
595 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
596 // Pre-select the currently chosen agent.
597 if ($agentId == $selected) {
598 $agentOptions .= ' selected="selected"';
599 }
600 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
601 }
602 wp_reset_postdata();
603
604 return $agentOptions;
605 }
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624 /**
625 * Delete property via SQL
626 *
627 * @param int $deleteId The ID of the property to delete.
628 * @param string $ListingKey The listing key of the property.
629 */
630 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
631 global $mlsimport;
632
633 // Resolve the post's type and the theme's expected property post type.
634 $postType = get_post_type($deleteId);
635 $propertyPostType = '';
636 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
637 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
638 }
639
640 // Only delete when the post is actually a property post type.
641 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
642 // GitHub issue #287: capture the attachment IDs BEFORE any deletion
643 // (they are found by post_parent, gone once the post row is), but do
644 // NOT delete them yet. File deletion is the only irreversible step,
645 // so it runs last — only after the post row is confirmed gone.
646 $attachments = get_posts([
647 'numberposts' => -1,
648 'post_type' => 'attachment',
649 'post_parent' => $deleteId,
650 'post_status' => null,
651 'fields' => 'ids',
652 ]);
653
654 // Capture the current status term names for the delete log.
655 $termObjList = get_the_terms($deleteId, 'property_status');
656 $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
657
658 // Re-read the identity from protected meta (issue #286); an empty key
659 // means a manually added listing.
660 $ListingKey = get_post_meta($deleteId, '_mlsimport_listing_key', true);
661 if ('' === $ListingKey) { // manually added listing
662 // Never delete user-created listings; log and bail.
663 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
664 $this->writeImportLogs($logEntry, 'delete');
665 return;
666 }
667
668 // Capture the owning task before its meta row is deleted below; the
669 // success activity entry still needs it afterward.
670 $ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true));
671
672 global $wpdb;
673 // Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the
674 // property's term relationships, term counts or listings row. Do that
675 // cleanup explicitly (SQL-first) before removing the post itself.
676 // Standalone mode: purge the plugin's own term/listings relations first.
677 if ( class_exists( 'Mlsimport_Standalone_Row' ) ) {
678 Mlsimport_Standalone_Row::purge_post_relations( $deleteId );
679 }
680 // Raw delete of the post's meta, then the post and any remaining
681 // non-attachment children. Attachment rows and meta must survive this
682 // step so wp_delete_attachment() below can still remove their files.
683 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
684 $postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId));
685
686 // GitHub issue #287: a failed post delete must leave the listing fully
687 // intact — no attachment deletion, no "deleted" history, no telemetry.
688 if (false === $postsDeleted || $postsDeleted < 1) {
689 $logEntry = 'MYSQL DELETE FAILED -> Property with id ' . $deleteId . ' (' . $postType . ') and ' . $ListingKey . ' was NOT deleted; attachments left untouched' . PHP_EOL;
690 $this->writeImportLogs($logEntry, 'delete');
691 return;
692 }
693
694 // The post row is durably gone; removing the now-orphaned attachments
695 // (rows, meta, and files) can no longer strand a visible listing.
696 foreach ($attachments as $attachmentId) {
697 wp_delete_attachment($attachmentId, true);
698 }
699
700 // Record the deletion in the activity feed only after it happened.
701 mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' );
702 mlsimport_telemetry_bump( 'deleted' );
703
704 $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
705 $this->writeImportLogs($logEntry, 'delete');
706 }
707 }
708
709
710
711
712
713
714
715
716
717 /**
718 * Delegate one incoming property to the explicit Stored Listing Write module.
719 *
720 * ThemeImport translates the legacy Import Task option names once at this
721 * compatibility edge. Listing decisions, common normalization, ordering,
722 * persistence, media, activity, and terminal outcomes stay behind write().
723 *
724 * @param array<string, mixed> $property Raw RESO property.
725 * @param array<string, mixed> $itemIdArray Import Task identity.
726 * @param string $tipImport Manual or cron source.
727 * @param array<string, mixed> $mlsimportItemOptionData Legacy task options.
728 * @return array<string, mixed>|false Public write result, or false if unconfigured.
729 */
730 public function mlsimportSaasPrepareToImportPerItem( $property, $itemIdArray, $tipImport, $mlsimportItemOptionData ) {
731 // A ThemeImport object used only for static SaaS/reconciliation helpers has
732 // no writer. If a listing call reaches such an object, fail this item without
733 // mutating WordPress; shared task execution will continue with the next one.
734 if ( null === $this->stored_listing_write ) {
735 $this->writeImportLogs(
736 empty( $property['ListingKey'] )
737 ? 'ERROR: No Listing Key ' . PHP_EOL
738 : 'ERROR: Stored Listing Write is not configured.' . PHP_EOL,
739 (string) $tipImport
740 );
741 return false;
742 }
743
744 // Translate the shallow legacy option array into the stable module settings.
745 $settings = array(
746 'task_id' => (int) ( $itemIdArray['item_id'] ?? 0 ),
747 'source' => (string) $tipImport,
748 'statuses' => is_array( $mlsimportItemOptionData['mlsimport_item_standardstatus'] ?? null )
749 ? $mlsimportItemOptionData['mlsimport_item_standardstatus']
750 : array(),
751 'user_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_property_user'] ?? 0 ),
752 'assigned_agent_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_agent'] ?? 0 ),
753 'use_mls_agent' => ! empty( $mlsimportItemOptionData['mlsimport_item_use_mls_agent'] ),
754 'post_status' => (string) ( $mlsimportItemOptionData['mlsimport_item_property_status'] ?? 'publish' ),
755 'field_configuration' => is_array( $mlsimportItemOptionData['mlsimport_field_configuration'] ?? null )
756 ? $mlsimportItemOptionData['mlsimport_field_configuration']
757 : array(),
758 'title_format' => (string) ( $mlsimportItemOptionData['mlsimport_item_title_format'] ?? '' ),
759 'config_version' => (string) ( $mlsimportItemOptionData['mlsimport_write_config_version'] ?? '' ),
760 );
761
762 return $this->stored_listing_write->write( $property, $settings );
763 }
764
765
766
767
768
769
770
771
772 /**
773 * Check for property status against MLS item delete status to see if we keep or delete the listing.
774 * @param int $property_id
775 * @param string|array $mlsImportItemStatus
776 * @return bool True to keep, false to delete
777 */
778 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
779
780 // Resolve the taxonomy field-map, then read the property's current status term.
781 $mlsimport_fields_opt = mlsimport_active_field_configuration();
782 $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
783 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
784 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
785
786 // Protected statuses: keep if property status matches
787 if (!empty($mlsImportItemStatusProtect)) {
788 // An unreadable status cannot prove the listing is NOT protected — keep and log.
789 if ('' === $post_status) {
790 $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, cannot check it against Protected Statuses' . PHP_EOL, 'delete');
791 return true;
792 }
793 // Normalise the protect list to space-free enum keys.
794 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
795 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
796 : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
797 // Property status is protected → keep it.
798 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
799 return true;
800 }
801 }
802
803 // Default: delete if not protected
804 return false;
805 }
806
807
808
809
810 /**
811 * Manual-import variant of the keep/delete status check.
812 *
813 * @param int $property_id The property post ID.
814 * @param array|string $mlsImportItemStatus Task's selected statuses.
815 * @return bool True if the property's status matches the selected set.
816 */
817 public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
818 // Normalize status arrays/strings to a space-free comparison key so
819 // Trestle PrettyEnums labels match the raw enum config values.
820 $mlsImportItemStatus = is_array($mlsImportItemStatus)
821 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
822 : mlsimport_normalize_status_enum($mlsImportItemStatus);
823
824 // Resolve the taxonomy field-map, then read the property's current status term.
825 $mlsimport_fields_opt = mlsimport_active_field_configuration();
826 $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
827 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
828 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
829
830 // An unreadable status is our read failing, not proof the listing should go — keep and log.
831 if ('' === $post_status) {
832 $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, deletion requires a readable status' . PHP_EOL, 'delete');
833 return true;
834 }
835
836 // Keep if status matches "keep" status (array membership or scalar equality).
837 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
838 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
839
840 return true;
841 }
842
843
844
845 // Default: status read but doesn't match the task's selection → delete.
846 return false;
847 }
848
849
850
851
852
853
854
855 /**
856 * Check if we should keep or delete the listing when still in MLS.
857 * true we keep
858 */
859 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
860 // Resolve the taxonomy field-map, then read the property's current status term.
861 $mlsimport_fields_opt = mlsimport_active_field_configuration();
862 $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
863 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
864 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
865
866 // The listing is still in the MLS feed. An unreadable local status is
867 // never proof it should be deleted (every past mass-deletion incident
868 // was this read failing) — keep and log.
869 if ('' === $post_status) {
870 $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: still in MLS feed but local status unreadable' . PHP_EOL, 'delete');
871 return true;
872 }
873
874 // Protected statuses: keep if property status matches
875 if (!empty($mlsimport_item_standardstatusprotect)) {
876 // Normalise the protect list to space-free enum keys.
877 $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
878 ? array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatusprotect)
879 : array(mlsimport_normalize_status_enum($mlsimport_item_standardstatusprotect));
880 // Protected → keep.
881 if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
882 return true;
883 }
884 }
885
886 // Early return if MLS status empty
887 if (empty($mlsimport_item_standardstatus)) {
888 return true; // default: keep if no status set
889 }
890
891 // Normalize standard statuses to a space-free key for comparison
892 if (is_array($mlsimport_item_standardstatus)) {
893 // Array form → keep when the property's status is a member.
894 $mlsimport_item_standardstatus = array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatus);
895 return in_array($post_status, $mlsimport_item_standardstatus, true);
896 }
897 // Scalar form → keep on exact (normalised) match.
898 return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus);
899 }
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915 }
916