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 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 6.0.7 All 35 releases
mlsimport / includes / ThemeImport.php

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

949 lines 34.9 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 * '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
346 */
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 }
370 }
371
372 /**
373 * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
374 *
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.
383 */
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'] : '';
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 }
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 }
412
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);
431
432 // Transport failure → count and abort (lock released for the next try).
433 if (is_wp_error($response)) {
434 mlsimport_telemetry_bump( 'token_failures' );
435 delete_option( 'mlsimport_token_refresh_lock' );
436 return false;
437 }
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 );
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 }
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']);
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);
470
471 // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
472 update_option('mlsimport_token_expiry', intval($data['expires']));
473
474 // First successful SaaS account connection (lifecycle telemetry).
475 mlsimport_telemetry_set_once( 'account_connected_at', time() );
476
477 // Refresh finished — release the single-flight lock.
478 delete_option( 'mlsimport_token_refresh_lock' );
479
480 // A minted token proves the account works → back to healthy.
481 self::setConnectionHealth( 'healthy' );
482
483 return true;
484 }
485
486
487
488
489
490
491
492
493
494
495
496 /**
497 * Write logs for import process
498 *
499 * @param string $logs The log message to write.
500 * @param string $type The type of log.
501 */
502 private function writeImportLogs($logs, $type) {
503 mlsimport_saas_single_write_import_custom_logs($logs, $type);
504 }
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
546
547
548
549
550
551
552
553
554
555
556 /**
557 * Return user option
558 *
559 * @param int $selected The selected user ID.
560 * @return string The HTML option elements for users.
561 */
562 public function mlsimportSaasThemeImportSelectUser($selected) {
563 $userOptions = '';
564 // Fetch all users to build a <select> of possible property authors.
565 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
566 foreach ($blogusers as $user) {
567 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
568 // Pre-select the currently chosen user.
569 if ($user->ID == $selected) {
570 $userOptions .= ' selected="selected"';
571 }
572 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
573 }
574 return $userOptions;
575 }
576
577
578
579
580
581
582
583 /**
584 * Return agent option
585 *
586 * @param int $selected The selected agent ID.
587 * @return string The HTML option elements for agents.
588 */
589 public function mlsimportSaasThemeImportSelectAgent($selected) {
590 global $mlsimport;
591 // Query up to 150 published agents of the theme's agent post type.
592 $args = [
593 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
594 'post_status' => 'publish',
595 'posts_per_page' => 150,
596 ];
597
598 $agentSelection = new WP_Query($args);
599 // Start with a blank option (no agent).
600 $agentOptions = '<option value=""></option>';
601
602 // Build one <option> per agent post.
603 while ($agentSelection->have_posts()) {
604 $agentSelection->the_post();
605 $agentId = get_the_ID();
606
607 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
608 // Pre-select the currently chosen agent.
609 if ($agentId == $selected) {
610 $agentOptions .= ' selected="selected"';
611 }
612 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
613 }
614 wp_reset_postdata();
615
616 return $agentOptions;
617 }
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636 /**
637 * Delete property via SQL
638 *
639 * @param int $deleteId The ID of the property to delete.
640 * @param string $ListingKey The listing key of the property.
641 */
642 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
643 global $mlsimport;
644
645 // Resolve the post's type and the theme's expected property post type.
646 $postType = get_post_type($deleteId);
647 $propertyPostType = '';
648 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
649 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
650 }
651
652 // Only delete when the post is actually a property post type.
653 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
654 // GitHub issue #287: capture the attachment IDs BEFORE any deletion
655 // (they are found by post_parent, gone once the post row is), but do
656 // NOT delete them yet. File deletion is the only irreversible step,
657 // so it runs last — only after the post row is confirmed gone.
658 $attachments = get_posts([
659 'numberposts' => -1,
660 'post_type' => 'attachment',
661 'post_parent' => $deleteId,
662 'post_status' => null,
663 'fields' => 'ids',
664 ]);
665
666 // Capture the current status term names for the delete log.
667 $termObjList = get_the_terms($deleteId, 'property_status');
668 $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
669
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 }
679
680 // Capture the owning task before its meta row is deleted below; the
681 // success activity entry still needs it afterward.
682 $ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true));
683
684 // Dedupe (issue #282): this raw-SQL path bypasses the WP delete
685 // hooks, so capture the address group now (meta is gone after the
686 // raw delete) and re-evaluate it after success — deleting a flagged
687 // winner must promote its hidden loser.
688 $dedupeAddressKey = (string) get_post_meta($deleteId, 'mlsimport_address_key', true);
689 // Telemetry (#283): the deletion counts against the
690 // listing's OWN connection — read the provenance stamp
691 // (#278) before the raw delete wipes its meta.
692 $provenanceMlsId = (int) get_post_meta($deleteId, 'mlsimport_mls_id', true);
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));
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 }
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 }
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 }
727
728 // Record the deletion in the activity feed only after it happened.
729 mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' );
730 mlsimport_telemetry_bump( 'deleted', 1, $provenanceMlsId );
731
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 }
736
737
738
739
740
741
742
743
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 }
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 );
794
795 return $this->stored_listing_write->write( $property, $settings );
796 }
797
798
799
800
801
802
803
804
805 /**
806 * Check for property status against MLS item delete status to see if we keep or delete the listing.
807 * @param int $property_id
808 * @param string|array $mlsImportItemStatus
809 * @return bool True to keep, false to delete
810 */
811 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
812
813 // Resolve the taxonomy field-map, then read the property's current status term.
814 $mlsimport_fields_opt = mlsimport_active_field_configuration();
815 $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
816 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
817 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
818
819 // Protected statuses: keep if property status matches
820 if (!empty($mlsImportItemStatusProtect)) {
821 // An unreadable status cannot prove the listing is NOT protected — keep and log.
822 if ('' === $post_status) {
823 $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, cannot check it against Protected Statuses' . PHP_EOL, 'delete');
824 return true;
825 }
826 // Normalise the protect list to space-free enum keys.
827 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
828 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
829 : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
830 // Property status is protected → keep it.
831 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
832 return true;
833 }
834 }
835
836 // Default: delete if not protected
837 return false;
838 }
839
840
841
842
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);
856
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);
862
863 // An unreadable status is our read failing, not proof the listing should go — keep and log.
864 if ('' === $post_status) {
865 $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, deletion requires a readable status' . PHP_EOL, 'delete');
866 return true;
867 }
868
869 // Keep if status matches "keep" status (array membership or scalar equality).
870 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
871 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
872
873 return true;
874 }
875
876
877
878 // Default: status read but doesn't match the task's selection → delete.
879 return false;
880 }
881
882
883
884
885
886
887
888 /**
889 * Check if we should keep or delete the listing when still in MLS.
890 * true we keep
891 */
892 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
893 // Resolve the taxonomy field-map, then read the property's current status term.
894 $mlsimport_fields_opt = mlsimport_active_field_configuration();
895 $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
896 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
897 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
898
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 }
906
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 }
918
919 // Early return if MLS status empty
920 if (empty($mlsimport_item_standardstatus)) {
921 return true; // default: keep if no status set
922 }
923
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 }
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948 }
949