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
← All changes | includes/ThemeImport.php +231 -1751 7.0.67.2 View file →
@@ -1,21 +1,18 @@
1 1 <?php
2 2 /**
3 - * ThemeImport — SaaS API client + property import/media pipeline.
3 + * ThemeImport — SaaS API client and Stored Listing Write compatibility edge.
4 4 *
5 - * This class is the heart of the MLSImport data flow. It owns two related jobs:
6 - * 1) Talking to the MLSImport SaaS API (AWS API Gateway): OAuth token
7 - * validation/refresh and the three request helpers
8 - * (globalApiRequestCurlSaas / globalApiRequestSaas / *FireAndForget).
9 - * 2) Turning a decoded RESO property record into a WordPress post: deciding
10 - * insert vs update vs delete by status, writing post meta / taxonomies,
11 - * attaching media, rebuilding the gallery, setting the featured image and
12 - * the title, plus heavy memory-management scaffolding around each step.
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.
13 10 *
14 - * NOTE: the import/media/gallery logic has known data-loss subtleties (stale
15 - * featured-image references, gallery overwrites, failed-attachment handling).
16 - * The inline comments below DESCRIBE what each step does; they do not assert
17 - * that the behaviour is correct.
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
18 15 */
19 16 if ( ! defined( 'ABSPATH' ) ) {
20 17 exit; // Exit if accessed directly
21 18 }
@@ -20,11 +17,9 @@
20 17 exit; // Exit if accessed directly
21 18 }
22 19
23 20 /**
24 - * Description of ThemeImport
25 - *
26 - * @class ThemeImport
21 + * Expose legacy API/batch methods around the explicit listing-write module.
27 22 */
28 23 class ThemeImport {
29 24
30 25
@@ -35,8 +30,26 @@
35 30 // Environment adapter instance (theme-specific meta mapping).
36 31 public $enviroment;
37 32 // Cached encoded credential/config values.
38 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 + }
39 52
40 53
41 54 /**
42 55 * Api Request to MLSimport API using CURL
@@ -90,10 +103,18 @@
90 103
91 104 // Dispatch as GET or POST depending on $type.
92 105 $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
93 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 + }
94 116
95 -
96 117 // Transport-level failure: return the WP_Error message string.
97 118 if (is_wp_error($response)) {
98 119 return $response->get_error_message();
99 120 } else {
@@ -218,10 +239,21 @@
218 239 ];
219 240 // Always POST (even for logical GETs) — the SaaS expects a JSON body.
220 241 $response = wp_remote_post($url, $args);
221 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 + }
222 255
223 -
224 256 // Transport error → structured failure with WP error code/message.
225 257 if (is_wp_error($response)) {
226 258 return [
227 259 'success' => false,
@@ -298,13 +330,55 @@
298 330 return true;
299 331 }
300 332
301 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 + /**
302 373 * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
303 374 *
304 375 * Reads the stored username/password, POSTs them, and on success stores the
305 376 * token in a transient plus the expiry timestamp in an option. Bumps the
306 - * 'token_failures' telemetry counter on every failure path.
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.
307 381 *
308 382 * @return bool True on successful refresh, false otherwise.
309 383 */
310 384 private static function refreshToken() {
@@ -318,11 +392,25 @@
318 392
319 393 // No credentials configured → cannot refresh.
320 394 if (empty($username) || empty($password)) {
321 395 mlsimport_telemetry_bump( 'token_failures' );
396 + self::setConnectionHealth( 'credentials_missing' );
322 397 return false;
323 398 }
324 -
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 +
325 413 // Prepare token request
326 414 $url = MLSIMPORT_API_URL . 'token';
327 415 $body = wp_json_encode(array(
328 416 'username' => $username,
@@ -340,11 +428,12 @@
340 428
341 429 // Make token request
342 430 $response = wp_remote_post($url, $args);
343 431
344 - // Transport failure → count and abort.
432 + // Transport failure → count and abort (lock released for the next try).
345 433 if (is_wp_error($response)) {
346 434 mlsimport_telemetry_bump( 'token_failures' );
435 + delete_option( 'mlsimport_token_refresh_lock' );
347 436 return false;
348 437 }
349 438
350 439 // Decode the JSON token response.
@@ -349,14 +438,29 @@
349 438
350 439 // Decode the JSON token response.
351 440 $body = wp_remote_retrieve_body($response);
352 441 $data = json_decode($body, true);
442 + $code = intval( $response['response']['code'] ?? 0 );
353 443
354 444 // Reject any response missing success/token/expires.
355 445 if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
356 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 + }
357 458 return false;
358 459 }
460 +
461 + // A working login wipes any remembered failure reason (#322).
462 + mlsimport_account_status_record( $data );
359 463
360 464 // Store new token and expiry
361 465 //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
362 466
@@ -369,207 +473,24 @@
369 473
370 474 // First successful SaaS account connection (lifecycle telemetry).
371 475 mlsimport_telemetry_set_once( 'account_connected_at', time() );
372 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 +
373 483 return true;
374 484 }
375 485
376 486
377 -/**
378 - * Parse a batch of listings for one import task (manual/normal path) and
379 - * import each property, with aggressive per-item memory management.
380 - *
381 - * @param array $readyToParseArray The array ready to be parsed.
382 - * @param array $itemIdArray The item ID array.
383 - * @param string $batchKey The batch key.
384 - * @param array $mlsimportItemOptionData The item option data.
385 - */
386 -public function mlsimportSaasParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey, $mlsimportItemOptionData) {
387 - // Start with aggressive memory cleanup
388 - $this->cleanUpMemory(true);
389 -
390 - // Log initial memory usage
391 - $initialMemory = memory_get_usage(true);
392 487
393 - // Running counter of processed properties + the trimmed working set.
394 - $counterProp = 0;
395 - $processedData = [];
396 488
397 - // Only proceed if the payload actually carries a 'data' array of listings.
398 - if (isset($readyToParseArray['data']) && is_array($readyToParseArray['data'])) {
399 - // Log total items to process
400 - $totalItems = count($readyToParseArray['data']);
401 489
402 490
403 491
404 492
405 - // Only keep essential data in memory, discard the rest
406 - foreach ($readyToParseArray['data'] as $key => $property) {
407 -
408 -
409 -
410 - // Save only what's needed from each property
411 - // (only listings with a ListingKey are retained).
412 - if (isset($property['ListingKey'])) {
413 - $processedData[$key] = $property;
414 - }
415 - // Remove from original array to free memory
416 - unset($readyToParseArray['data'][$key]);
417 - }
418 -
419 - // Complete unset of the original array
420 - unset($readyToParseArray);
421 - $this->cleanUpMemory();
422 -
423 -
424 - // Resolve the import task post ID and its running progress counter.
425 - $mlsimportItemId = intval($itemIdArray['item_id']);
426 -
427 - $current_prop_value = (int) get_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', true );
428 -
429 -
430 - // Process each property
431 - foreach ($processedData as $key => $property) {
432 - // Advance the processed-property counter.
433 - ++$counterProp;
434 -
435 - // Memory usage before processing property
436 - $memoryBefore = memory_get_usage(true);
437 - $memoryBeforeMB = round($memoryBefore / 1048576, 2);
438 -
439 - // Identify the listing (used for logging context).
440 - $listingKey = isset($property['ListingKey']) ? $property['ListingKey'] : 'unknown';
441 -
442 - // Clear out database caches that might be polluted
443 - // so the force-stop flag below is read fresh, not from cache.
444 - wp_cache_delete('mlsimport_force_stop_' . $itemIdArray['item_id'], 'options');
445 - $GLOBALS['wpdb']->queries = array();
446 -
447 - // Read the force-stop flag: 'no' means keep importing.
448 - $status = get_option('mlsimport_force_stop_' . $itemIdArray['item_id']);
449 -
450 - // Not force-stopped → import this property.
451 - if ($status === 'no') {
452 -
453 - // Bump and persist the progress counter for the UI.
454 - $current_prop_value = $current_prop_value + 1;
455 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', $current_prop_value );
456 -
457 -
458 -
459 - // Process property and track memory
460 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'normal', $mlsimportItemOptionData);
461 -
462 - // Memory after processing property
463 - $memoryAfter = memory_get_usage(true);
464 - $memoryAfterMB = round($memoryAfter / 1048576, 2);
465 - $memoryDiff = round(($memoryAfter - $memoryBefore) / 1048576, 2);
466 -
467 -
468 - // Check for memory leak pattern
469 - // (a >10MB jump for one property triggers a hard cleanup).
470 - if ($memoryDiff > 10) {
471 - // Force cleanup on large increases
472 - $this->cleanUpMemory(true);
473 - }
474 -
475 - // Aggressively clean after each property
476 - unset($property);
477 -
478 - // Periodic more intensive cleanup
479 - // (every 3rd property: flush query + autoloaded-options caches).
480 - if ($counterProp % 3 == 0) {
481 - $this->cleanUpMemory(true);
482 -
483 - // Free database query cache
484 - $GLOBALS['wpdb']->flush();
485 -
486 - // Clear autoloaded options cache, which can grow large
487 - wp_cache_delete('alloptions', 'options');
488 -
489 - // Log memory after cleanup
490 - $memoryAfterCleanup = memory_get_usage(true);
491 - $freedMemory = round(($memoryAfter - $memoryAfterCleanup) / 1048576, 2);
492 - }
493 - } else {
494 - // Force-stopped: mark the task completed and stop the loop.
495 - update_post_meta($itemIdArray['item_id'], 'mlsimport_spawn_status', 'completed');
496 - break;
497 - }
498 -
499 - // Clear property from processed data to free memory
500 - unset($processedData[$key]);
501 - }
502 - } else {
503 - // No 'data' array present — nothing to import (branch intentionally empty).
504 - }
505 -
506 - // Final cleanup
507 - unset($processedData);
508 - $this->cleanUpMemory(true);
509 -
510 - // Log final memory stats
511 - // (values computed for diagnostics; logging call sites are commented out).
512 - $finalMemory = memory_get_usage(true);
513 - $finalMemoryMB = round($finalMemory / 1048576, 2);
514 - $totalMemoryDiff = round(($finalMemory - $initialMemory) / 1048576, 2);
515 - $peakMemory = round(memory_get_peak_usage(true) / 1048576, 2);
516 -
517 -}
518 -
519 -
520 -/**
521 - * Comprehensive memory cleanup function
522 - *
523 - * @param bool $intensive Whether to perform intensive cleanup
524 - */
525 -private function cleanUpMemory($intensive = false) {
526 - // Basic cleanup
527 - wp_cache_flush();
528 - gc_collect_cycles();
529 -
530 - // Intensive mode adds object/post/term/db cache clearing and extra GC passes.
531 - if ($intensive) {
532 - // Clear WordPress object cache
533 - global $wp_object_cache;
534 - if (is_object($wp_object_cache) && method_exists($wp_object_cache, 'flush')) {
535 - $wp_object_cache->flush();
536 - }
537 -
538 - // Clear WordPress post caches
539 - clean_post_cache(0);
540 -
541 - // Safe term cache clearing - avoid SQL errors
542 - wp_cache_delete('get_terms', 'terms');
543 - wp_cache_delete('term_meta', 'terms');
544 - delete_option('category_children');
545 -
546 - // Clear taxonomy-specific caches for common taxonomies
547 - $taxonomies = array('category', 'post_tag', 'property_status', 'property_type', 'property_feature', 'property_label', 'property_area', 'property_city', 'property_state', 'property_neighborhood');
548 - // Drop each taxonomy's cached relationships bucket.
549 - foreach ($taxonomies as $taxonomy) {
550 - wp_cache_delete($taxonomy . '_relationships', 'terms');
551 - }
552 -
553 - // Clear WordPress database cache
554 - global $wpdb;
555 - if (is_object($wpdb)) {
556 - // Reset the saved-query log and flush wpdb's internal caches.
557 - $wpdb->queries = array();
558 - if (method_exists($wpdb, 'flush')) {
559 - $wpdb->flush();
560 - }
561 - }
562 -
563 - // Multiple garbage collection passes can sometimes help
564 - gc_collect_cycles();
565 - gc_collect_cycles();
566 - }
567 -}
568 -
569 -
570 -
571 -
572 493
573 494
574 495
575 496 /**
@@ -581,18 +502,8 @@
581 502 private function writeImportLogs($logs, $type) {
582 503 mlsimport_saas_single_write_import_custom_logs($logs, $type);
583 504 }
584 505
585 - /**
586 - * Get memory usage
587 - *
588 - * @return string The memory usage in MB.
589 - */
590 - public function mlsimportMemUsage() {
591 - $memUsage = memory_get_usage(true);
592 - $memUsageShow = round($memUsage / 1048576, 2);
593 - return $memUsageShow . 'mb ';
594 - }
595 506
596 507
597 508
598 509
@@ -597,62 +508,15 @@
597 508
598 509
599 510
600 511
601 - /**
602 - * Parse and import property data for a single MLSimport item in CRON.
603 - * Logs memory usage for each significant operation.
604 - *
605 - * @param array $readyToParseArray The array with listing data (from API).
606 - * @param array $itemIdArray The array with current MLSimport item info.
607 - * @param string $batchKey The batch identifier for logging.
608 - */
609 - public function mlsimportSaasCronParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey) {
610 - // Gather relevant meta for this MLSimport item
611 - $mlsimportItemOptionData = [
612 - 'mlsimport_item_standardstatus' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatus', true),
613 - 'mlsimport_item_standardstatusprotect' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatusprotect', true),
614 - 'mlsimport_item_property_user' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_user', true),
615 - 'mlsimport_item_agent' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_agent', true),
616 - 'mlsimport_item_use_mls_agent' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_use_mls_agent', true),
617 - 'mlsimport_item_property_status' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_status', true),
618 - ];
619 512
620 - $count = isset($readyToParseArray['data']) && is_array($readyToParseArray['data']) ? count($readyToParseArray['data']) : 0;
621 - $log = '[Memory] Start batch ' . $batchKey . ' with ' . $count . ' listings: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
622 - $this->writeImportLogs($log, 'cron');
623 513
624 - if ($count === 0) {
625 - $this->writeImportLogs('[Memory] No data to parse in batch ' . $batchKey, 'cron');
626 - return;
627 - }
628 514
629 - foreach ($readyToParseArray['data'] as $key => $property) {
630 - // Log at the start of each property (optional, comment out if too verbose)
631 - //$log = '[Memory] Before import property #' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
632 - //$this->writeImportLogs($log, 'cron');
633 515
634 - $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batchKey . ' with ListingKey: ' . $property['ListingKey'] . PHP_EOL;
635 - $this->writeImportLogs($logs, 'cron');
636 516
637 - // Main per-property import function (handles mapping/import/update)
638 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'cron', $mlsimportItemOptionData);
639 517
640 - // Clean up per-iteration memory
641 - unset($property);
642 - if (($key + 1) % 20 === 0) {
643 - gc_collect_cycles();
644 - $log = '[Memory] After importing ' . ($key + 1) . ' listings in batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
645 - $this->writeImportLogs($log, 'cron');
646 - }
647 - }
648 - // Final memory log for this batch
649 - $this->writeImportLogs('[Memory] End batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron');
650 518
651 - // Housekeeping
652 - unset($readyToParseArray, $mlsimportItemOptionData);
653 - gc_collect_cycles();
654 - }
655 519
656 520
657 521
658 522
@@ -662,484 +526,35 @@
662 526
663 527
664 528
665 529
666 - /**
667 - * Check if property already imported
668 - *
669 - * @param string $key The key to search for.
670 - * @param string $postType The post type to search within (default is 'estate_property').
671 - * @return int The post ID if found, or 0 if not found.
672 - */
673 - public function mlsimportSaasRetrievePropertyById($key, $postType = 'estate_property') {
674 - // Look up any post of $postType whose ListingKey meta equals $key (IDs only).
675 - $args = [
676 - 'post_type' => $postType,
677 - 'post_status' => 'any',
678 - 'meta_query' => [
679 - [
680 - 'key' => 'ListingKey',
681 - 'value' => $key,
682 - 'compare' => '=',
683 - ],
684 - ],
685 - 'fields' => 'ids',
686 - ];
687 530
688 - $query = new WP_Query($args);
689 - // Match found → return the first post ID.
690 - if ($query->have_posts()) {
691 - $query->the_post();
692 - $propertyId = get_the_ID();
693 - wp_reset_postdata();
694 - return $propertyId;
695 - } else {
696 - // No existing property with this ListingKey.
697 - wp_reset_postdata();
698 - return 0;
699 - }
700 - }
701 531
702 532
703 533
704 534
705 - /**
706 - * Clear taxonomy
707 - *
708 - * @param int $propertyId The property ID.
709 - * @param array $taxonomies The taxonomies to clear.
710 - */
711 - public function mlsimportSaasClearPropertyForTaxonomy($propertyId, $taxonomies) {
712 - // Only iterate when given a taxonomy map.
713 - if (is_array($taxonomies)) {
714 - // Walk each taxonomy key from the incoming property data.
715 - foreach ($taxonomies as $taxonomy => $term) {
716 - // Defensive: skip a taxonomy key that is itself a WP_Error.
717 - if (is_wp_error($taxonomy)) {
718 -
719 - continue; // Skip this iteration
720 - }
721 535
722 - // Remove the property's existing term relationships for this taxonomy.
723 - if (taxonomy_exists($taxonomy)) {
724 - wp_delete_object_term_relationships($propertyId, $taxonomy);
725 - } else {
726 - // Unknown taxonomy — nothing to clear.
727 - }
728 - }
729 - }
730 - }
731 536
732 -
733 -
734 -
735 -
736 - /**
737 - * Set taxonomy for property
738 - *
739 - * @param string $taxonomy The taxonomy to set.
740 - * @param int $propertyId The property ID.
741 - * @param mixed $fieldValues The values to set.
742 - */
743 - public function mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $fieldValues) {
744 - global $wpdb;
745 -
746 - // Convert comma-separated values to array if necessary
747 - if (!is_array($fieldValues)) {
748 - $fieldValues = strpos($fieldValues, ',') !== false ? explode(',', $fieldValues) : [$fieldValues];
749 - }
750 -
751 - // Trim values and remove empty ones
752 - $fieldValues = array_filter(array_map('trim', $fieldValues));
753 -
754 - // Start a database transaction
755 - $wpdb->query('START TRANSACTION');
756 - $taxLog = [];
757 -
758 - // Process the term values in chunks of 5 (memory-friendly).
759 - foreach (array_chunk($fieldValues, 5) as $chunk) {
760 - // Each individual term value in the chunk.
761 - foreach ($chunk as $value) {
762 - if (!empty($value)) {
763 - // Check if the term already exists
764 - $term = $wpdb->get_row($wpdb->prepare(
765 - "SELECT t.*, tt.* FROM $wpdb->terms t
766 - INNER JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id
767 - WHERE t.name = %s AND tt.taxonomy = %s",
768 - $value, $taxonomy
769 - ));
770 -
771 - $taxLog[] = json_encode($term);
772 - // Term not found → create the term + its term_taxonomy row.
773 - if (is_null($term)) {
774 - // Insert the term if it doesn't exist
775 - $wpdb->insert($wpdb->terms, [
776 - 'name' => $value,
777 - 'slug' => sanitize_title($value),
778 - 'term_group' => 0
779 - ]);
780 -
781 - $termId = $wpdb->insert_id;
782 -
783 - // Term insert succeeded → add the taxonomy mapping row.
784 - if ($termId) {
785 - // Insert term taxonomy
786 - $wpdb->insert($wpdb->term_taxonomy, [
787 - 'term_id' => $termId,
788 - 'taxonomy' => $taxonomy,
789 - 'description' => '',
790 - 'parent' => 0,
791 - 'count' => 0
792 - ]);
793 -
794 - $termTaxonomyId = $wpdb->insert_id;
795 - } else {
796 - // Term insert failed → log and skip this value.
797 - $taxLog[] = 'Error inserting term';
798 - continue;
799 - }
800 - } else {
801 - // Term exists, get term_id and term_taxonomy_id
802 - $termId = $term->term_id;
803 - $termTaxonomyId = $wpdb->get_var($wpdb->prepare(
804 - "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s",
805 - $termId, $taxonomy
806 - ));
807 - }
808 -
809 - // With a valid term_taxonomy_id, link the property to the term and bump the count.
810 - if (!empty($termTaxonomyId)) {
811 - // Insert term relationship
812 - $wpdb->replace($wpdb->term_relationships, [
813 - 'object_id' => $propertyId,
814 - 'term_taxonomy_id' => $termTaxonomyId
815 - ]);
816 - // Location taxonomies (browse-by-city/area widgets) use a publish-aware
817 - // recompute so the displayed count matches the publish-only archive.
818 - // A blind +1 is not idempotent (re-imports inflate it) and ignores
819 - // post status, so a new city could show a count while its archive is
820 - // empty. These terms are low-cardinality (tens of listings), so the
821 - // COUNT is cheap. High-cardinality grouping taxonomies keep the O(1)
822 - // increment to avoid scanning thousands of rows per import.
823 - $location_taxonomies = array('property_city', 'property_area', 'property_state', 'property_neighborhood');
824 - // Location taxonomy → recompute count from publish-only posts.
825 - if (in_array($taxonomy, $location_taxonomies, true)) {
826 - // Mirrors WordPress' _update_post_term_count callback in SQL.
827 - $wpdb->query($wpdb->prepare(
828 - "UPDATE $wpdb->term_taxonomy tt
829 - SET count = (
830 - SELECT COUNT(*) FROM $wpdb->term_relationships tr
831 - INNER JOIN $wpdb->posts p ON p.ID = tr.object_id
832 - WHERE tr.term_taxonomy_id = tt.term_taxonomy_id
833 - AND p.post_status = 'publish'
834 - )
835 - WHERE tt.term_taxonomy_id = %d",
836 - $termTaxonomyId
837 - ));
838 - } else {
839 - // High-cardinality taxonomy → cheap O(1) count increment.
840 - $wpdb->query($wpdb->prepare(
841 - "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d",
842 - $termTaxonomyId
843 - ));
844 - }
845 - } else {
846 - // No term_taxonomy_id resolved → record the failure.
847 - $taxLog[] = 'Error: term_taxonomy_id is null';
848 - }
849 - }
850 - }
851 - // Flush the cache to free up memory
852 - wp_cache_flush();
853 - // Run garbage collection
854 - gc_collect_cycles();
855 - }
856 - // Commit the transaction
857 - $wpdb->query('COMMIT');
858 -
859 - // Clear term cache selectively
860 - wp_cache_delete("{$taxonomy}_terms", 'terms');
861 - wp_cache_delete("{$taxonomy}_children", 'terms');
862 -
863 - // Restore the term metadata filter
864 - add_filter('get_term_metadata', [$wpdb->terms, 'cache_term_counts'], 10, 2);
865 -
866 - // Log memory usage
867 - // if (!empty($taxLog)) {
868 - // $taxLogStr = implode(PHP_EOL, $taxLog);
869 - // mlsimport_saas_single_write_import_custom_logs($taxLogStr, 'normal');
870 - // unset($taxLogStr);
871 - // }
872 - }
873 -
874 -
875 -
876 -
877 - /**
878 - * Set Property Title
879 - *
880 - * @param int $propertyId The property ID.
881 - * @param int $mlsImportPostId The MLS import post ID.
882 - * @param array $property The property data.
883 - * @return string The updated title format.
884 - */
885 - public function mlsimportSaasUpdatePropertyTitle($propertyId, $mlsImportPostId, $property) {
886 - global $mlsimport;
887 -
888 - // Per-task title template; falls back to the global sync setting when unset.
889 - $titleFormat = esc_html(get_post_meta($mlsImportPostId, 'mlsimport_item_title_format', true));
890 -
891 - if ('' === $titleFormat) {
892 - $options = get_option('mlsimport_admin_mls_sync');
893 - $titleFormat = $options['title_format'];
894 - }
895 -
896 - // Extract the {token} placeholders present in the template.
897 - $titleArray = $this->strBetweenAll($titleFormat, '{', '}');
898 -
899 - // Lowercased copy of extra_meta for case-insensitive lookups below.
900 - $propertyExtraMetaArrayLowerCase = array_change_key_case($property['extra_meta'], CASE_LOWER);
901 -
902 - // Resolve each placeholder to its value and substitute it into the template.
903 - foreach ($titleArray as $key => $value) {
904 - $replace = '';
905 - // Map the known placeholder names to their source in the property data.
906 - switch ($value) {
907 - case 'Address':
908 - $replace = $property['adr_title'] ?? '';
909 - break;
910 - case 'City':
911 - $replace = $property['adr_city'] ?? '';
912 - break;
913 - case 'CountyOrParish':
914 - $replace = $property['adr_county'] ?? '';
915 - break;
916 - case 'PropertyType':
917 - $replace = $property['adr_type'] ?? '';
918 - break;
919 - case 'Bedrooms':
920 - $replace = $property['adr_bedrooms'] ?? '';
921 - break;
922 - case 'Bathrooms':
923 - $replace = $property['adr_bathrooms'] ?? '';
924 - break;
925 - case 'ListingKey':
926 - $replace = $property['ListingKey'];
927 - break;
928 - case 'ListingId':
929 - $replace = $property['adr_listingid'] ?? '';
930 - break;
931 - case 'StateOrProvince':
932 - $replace = $property['extra_meta']['StateOrProvince'] ?? '';
933 - break;
934 - case 'PostalCode':
935 - $replace = $property['meta']['property_zip'] ?? $property['meta']['fave_property_zip'] ?? '';
936 - $replace = is_array($replace) ? strval($replace[0]) : strval($replace);
937 - break;
938 - case 'StreetNumberNumeric':
939 - $replace = $propertyExtraMetaArrayLowerCase['streetnumbernumeric'] ?? '';
940 - break;
941 - case 'StreetName':
942 - $replace = $propertyExtraMetaArrayLowerCase['streetname'] ?? '';
943 - break;
944 - }
945 - // Replace this {token} occurrence with the resolved value.
946 - $titleFormat = str_replace('{' . $value . '}', $replace, $titleFormat);
947 - }
948 -
949 - // Persist the computed title (also reused as the post slug).
950 - $post = [
951 - 'ID' => $propertyId,
952 - 'post_title' => $titleFormat,
953 - 'post_name' => $titleFormat,
954 - ];
955 -
956 - wp_update_post($post);
957 -
958 - return $titleFormat;
959 - }
960 -
961 537
962 538
963 539
964 540
965 541
966 - /**
967 - * Prepare meta data for property
968 - *
969 - * @param array $property The property data.
970 - * @return array The property data with prepared meta.
971 - */
972 - public function mlsimportSaasPrepareMetaForProperty($property) {
973 - // BathroomsTotalDecimal is not provided by every MLS (e.g. BrightMLS sends only
974 - // BathroomsTotalInteger / BathroomsFull). Fall back so the theme Overview value
975 - // is not wiped to empty.
976 - $bathroomsRaw = $property['extra_meta']['BathroomsTotalDecimal']
977 - ?? $property['extra_meta']['BathroomsTotalInteger']
978 - ?? $property['extra_meta']['BathroomsFull']
979 - ?? '';
980 - $bathrooms = ( '' === $bathroomsRaw || null === $bathroomsRaw ) ? '' : floatval($bathroomsRaw);
981 - $property['meta']['property_bathrooms'] = $bathrooms;
982 - $property['meta']['fave_property_bathrooms'] = $bathrooms;
983 - $property['meta']['REAL_HOMES_property_bathrooms'] = $bathrooms;
984 -
985 - // PostalCode is commonly provided in normalized meta (property_zip) rather than extra_meta.
986 - // Mirror it into extra_meta when missing so field mappings (postmeta/taxonomy) can process it.
987 - if (!isset($property['extra_meta']) || !is_array($property['extra_meta'])) {
988 - $property['extra_meta'] = array();
989 - }
990 542
991 - $postal_code = '';
992 - if (isset($property['meta']) && is_array($property['meta'])) {
993 - if (!empty($property['meta']['property_zip'])) {
994 - $postal_code = $property['meta']['property_zip'];
995 - } elseif (!empty($property['meta']['fave_property_zip'])) {
996 - $postal_code = $property['meta']['fave_property_zip'];
997 - } elseif (!empty($property['meta']['REAL_HOMES_property_zip'])) {
998 - $postal_code = $property['meta']['REAL_HOMES_property_zip'];
999 - }
1000 - }
1001 543
1002 - if (is_array($postal_code)) {
1003 - $postal_code = reset($postal_code);
1004 - }
1005 - $postal_code = trim((string) $postal_code);
1006 544
1007 - if ('' !== $postal_code && empty($property['extra_meta']['PostalCode'])) {
1008 - $property['extra_meta']['PostalCode'] = $postal_code;
1009 - }
1010 545
1011 - return $property;
1012 - }
1013 -
1014 -
1015 -
1016 -
1017 546
1018 - /**
1019 - * Attach media to post
1020 - *
1021 - * @param int $propertyId The property ID.
1022 - * @param array $media The media data.
1023 - * @param string $isInsert Whether the property is being inserted.
1024 - * @return string The media history log.
1025 - */
1026 - public function mlsimportSassAttachMediaToPost($propertyId, $media, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia = false) {
1027 547
1028 - $mediaHistory = [];
1029 548
1030 - // Editing an existing property with unchanged media → keep current images, do nothing.
1031 - if ($isInsert === 'no' && !$shouldRefreshMedia) {
1032 - $mediaHistory[] = 'Media - We have edit - images are not replaced';
1033 - return $media_attachments;
1034 - }
1035 549
1036 - global $mlsimport;
1037 - // Needed for wp_insert_attachment / image handling helpers.
1038 - include_once ABSPATH . 'wp-admin/includes/image.php';
1039 - $hasFeatured = false;
1040 550
1041 551
1042 552
1043 553
1044 - // Suppress generation of intermediate image sizes while importing (see wpcUnsetImageSizes).
1045 - add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
1046 554
1047 555
1048 - // Only iterate when we actually received a media array.
1049 - if (is_array($media)) {
1050 - // Walk each incoming media item for this chunk.
1051 - foreach ($media as $key=>$image) {
1052 - // Skip anything that isn't a property photo (e.g. documents, virtual tours).
1053 - if (isset($image['MediaCategory']) && $image['MediaCategory'] !== 'Property Photo' && $image['MediaCategory'] !== 'Photo') {
1054 - continue;
1055 - }
1056 -
1057 - // Skip items without a usable image URL.
1058 - if ( empty( $image['MediaURL'] ) ) {
1059 - continue;
1060 - }
1061 -
1062 -
1063 -
1064 - // Build and insert the attachment record pointing at the remote MediaURL.
1065 - if (isset($image['MediaURL'])) {
1066 - $file = $image['MediaURL'];
1067 - $attachment = [
1068 - 'guid' => $file,
1069 - 'post_status' => 'inherit',
1070 - 'post_content' => '',
1071 - 'post_parent' => $propertyId,
1072 - 'post_mime_type' => $image['MimeType'] ?? 'image/jpeg',
1073 - 'post_title' => $image['MediaKey'] ?? '',
1074 - ];
1075 -
1076 -
1077 - // Create the attachment post; $attachId is an int (0 on failure).
1078 - $attachId = wp_insert_attachment($attachment, $file);
1079 - // wp_insert_attachment() returns 0 (int) on failure, not a
1080 - // WP_Error, so is_wp_error() alone lets a failed insert through
1081 - // and records a 0 — which poisons the gallery and makes the
1082 - // featured-image fallback set_post_thumbnail($id, 0) a no-op.
1083 - // Failed insert (WP_Error or 0) → skip; success → record + wire up the attachment.
1084 - if (is_wp_error($attachId) || ! $attachId) {
1085 - // Insert failed; do not add a 0/invalid id to the gallery.
1086 - } else {
1087 - $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId;
1088 - // Collect the new attachment id for the gallery list.
1089 - $media_attachments[]=$attachId;
1090 -
1091 -
1092 - // Let the theme adapter store the image on the property, and flag it as ours.
1093 - $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
1094 - update_post_meta($attachId, 'is_mlsimport', 1);
1095 -
1096 - // This item is the chosen featured image → set it as the post thumbnail.
1097 - if ($key===$featuredImageKey){
1098 -
1099 -
1100 - set_post_thumbnail($propertyId, $attachId);
1101 -
1102 - } else {
1103 - // Not the featured image — nothing extra to do.
1104 - }
1105 - }
1106 - } else {
1107 - // No MediaURL key — item skipped.
1108 - }
1109 - }
1110 - } else {
1111 - // $media was not an array — no images to attach.
1112 - $mediaHistory[] = 'Media data is blank - there are no images';
1113 - }
1114 -
1115 - // Restore normal intermediate-size generation.
1116 - remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
1117 -
1118 - // Return the accumulated attachment ids (gallery built by the caller).
1119 - return $media_attachments;
1120 - //return implode('</br>', $mediaHistory);
1121 - }
1122 -
1123 -
1124 556 /**
1125 - * Unset image sizes
1126 - *
1127 - * @param array $sizes The sizes to unset.
1128 - * @return array The modified sizes array.
1129 - */
1130 - public function wpcUnsetImageSizes($sizes) {
1131 - // Return an empty size set so WordPress generates no intermediate thumbnails.
1132 - return [];
1133 - }
1134 -
1135 -
1136 -
1137 -
1138 -
1139 -
1140 -
1141 - /**
1142 557 * Return user option
1143 558 *
1144 559 * @param int $selected The selected user ID.
1145 560 * @return string The HTML option elements for users.
@@ -1207,124 +622,19 @@
1207 622
1208 623
1209 624
1210 625
1211 - /**
1212 - * Delete property
1213 - *
1214 - * @param int $deleteId The ID of the property to delete.
1215 - * @param string $ListingKey The listing key of the property.
1216 - */
1217 - public function deleteProperty($deleteId, $ListingKey) {
1218 - // Only act on a valid post ID.
1219 - if ($deleteId > 0) {
1220 - // Record the deletion in the activity log before removing anything.
1221 - mlsimport_record_activity( 'deleted', $deleteId, get_post_meta($deleteId,'ListingKey',true), intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'import' );
1222 - // Gather all child attachments of this property.
1223 - $args = [
1224 - 'numberposts' => -1,
1225 - 'post_type' => 'attachment',
1226 - 'post_parent' => $deleteId,
1227 - 'post_status' => null,
1228 - 'orderby' => 'menu_order',
1229 - 'order' => 'ASC',
1230 - ];
1231 - $postAttachments = get_posts($args);
1232 626
1233 - // Delete each attachment post.
1234 - foreach ($postAttachments as $attachment) {
1235 - wp_delete_post($attachment->ID);
1236 - }
1237 627
1238 - // Delete the property post itself and bump the telemetry counter.
1239 - wp_delete_post($deleteId);
1240 - mlsimport_telemetry_bump( 'deleted' );
1241 - $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1242 - $this->writeImportLogs($logEntry, 'delete');
1243 - }
1244 - }
1245 628
1246 629
1247 630
1248 631
1249 - /**
1250 - * Return array with title items
1251 - *
1252 - * @param string $string The input string.
1253 - * @param string $start The start delimiter.
1254 - * @param string $end The end delimiter.
1255 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
1256 - * @param int $offset The offset to start searching from.
1257 - * @return array The array of strings found between the delimiters.
1258 - */
1259 - public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
1260 - $strings = [];
1261 - $length = strlen($string);
1262 632
1263 - // Repeatedly extract the next delimited substring until none remain.
1264 - while ($offset < $length) {
1265 - $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
1266 - // No further match → stop.
1267 - if ($found === null) {
1268 - break;
1269 - }
1270 633
1271 - $strings[] = $found;
1272 - $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
1273 - }
1274 634
1275 - return $strings;
1276 - }
1277 635
1278 636 /**
1279 - * Find string between delimiters
1280 - *
1281 - * @param string $string The input string.
1282 - * @param string $start The start delimiter.
1283 - * @param string $end The end delimiter.
1284 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
1285 - * @param int $offset The offset to start searching from.
1286 - * @return string|null The string found between the delimiters, or null if not found.
1287 - */
1288 - public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
1289 - // Empty inputs cannot yield a match.
1290 - if ($string === '' || $start === '' || $end === '') {
1291 - return null;
1292 - }
1293 -
1294 - $startLength = strlen($start);
1295 - $endLength = strlen($end);
1296 -
1297 - // Locate the start delimiter from the current offset.
1298 - $startPos = strpos($string, $start, $offset);
1299 - if ($startPos === false) {
1300 - return null;
1301 - }
1302 -
1303 - // Locate the end delimiter after the start delimiter.
1304 - $endPos = strpos($string, $end, $startPos + $startLength);
1305 - if ($endPos === false) {
1306 - return null;
1307 - }
1308 -
1309 - // Compute the substring length (with or without delimiters).
1310 - $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
1311 - // Empty span between adjacent delimiters.
1312 - if (!$length) {
1313 - return '';
1314 - }
1315 -
1316 - // Advance offset to the substring start and slice it out.
1317 - $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
1318 -
1319 - return substr($string, $offset, $length);
1320 - }
1321 -
1322 -
1323 -
1324 -
1325 -
1326 - /**
1327 637 * Delete property via SQL
1328 638 *
1329 639 * @param int $deleteId The ID of the property to delete.
1330 640 * @param string $ListingKey The listing key of the property.
@@ -1340,9 +650,12 @@
1340 650 }
1341 651
1342 652 // Only delete when the post is actually a property post type.
1343 653 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
1344 - // Delete attachments using WordPress functions so the files are removed as well
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.
1345 658 $attachments = get_posts([
1346 659 'numberposts' => -1,
1347 660 'post_type' => 'attachment',
1348 661 'post_parent' => $deleteId,
@@ -1349,19 +662,15 @@
1349 662 'post_status' => null,
1350 663 'fields' => 'ids',
1351 664 ]);
1352 665
1353 - // Remove each attachment (and its underlying file).
1354 - foreach ($attachments as $attachmentId) {
1355 - wp_delete_attachment($attachmentId, true);
1356 - }
1357 -
1358 666 // Capture the current status term names for the delete log.
1359 667 $termObjList = get_the_terms($deleteId, 'property_status');
1360 - $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
668 + $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
1361 669
1362 - // Re-read ListingKey from meta; an empty key means a manually added listing.
1363 - $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
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);
1364 673 if ('' === $ListingKey) { // manually added listing
1365 674 // Never delete user-created listings; log and bail.
1366 675 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1367 676 $this->writeImportLogs($logEntry, 'delete');
@@ -1367,11 +676,22 @@
1367 676 $this->writeImportLogs($logEntry, 'delete');
1368 677 return;
1369 678 }
1370 679
1371 - // Log the reconciliation-driven deletion in the activity feed.
1372 - mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'reconciliation' );
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));
1373 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 +
1374 694 global $wpdb;
1375 695 // Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the
1376 696 // property's term relationships, term counts or listings row. Do that
1377 697 // cleanup explicitly (SQL-first) before removing the post itself.
@@ -1378,13 +698,38 @@
1378 698 // Standalone mode: purge the plugin's own term/listings relations first.
1379 699 if ( class_exists( 'Mlsimport_Standalone_Row' ) ) {
1380 700 Mlsimport_Standalone_Row::purge_post_relations( $deleteId );
1381 701 }
1382 - // Raw delete of the post's meta, then the post and any remaining children.
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.
1383 705 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
1384 - $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
1385 - mlsimport_telemetry_bump( 'deleted' );
706 + $postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId));
1386 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 +
1387 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;
1388 733 $this->writeImportLogs($logEntry, 'delete');
1389 734 }
1390 735 }
@@ -1397,294 +742,58 @@
1397 742
1398 743
1399 744
1400 745 /**
1401 - * Prepare to import per item
746 + * Delegate one incoming property to the explicit Stored Listing Write module.
1402 747 *
1403 - * @param array $property The property data.
1404 - * @param array $itemIdArray The item ID array.
1405 - * @param string $tipImport The import type.
1406 - * @param array $mlsimportItemOptionData The item option data.
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.
1407 757 */
1408 -public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
1409 - // Pre-execution memory optimization
1410 - wp_cache_flush();
1411 - gc_collect_cycles();
1412 -
1413 - // Temporarily disable WordPress hooks that might add to memory usage
1414 - // (saved into $saved_filters and restored at the end of the method).
1415 - global $wp_filter;
1416 - $saved_filters = array();
1417 - if (isset($wp_filter['transition_post_status'])) {
1418 - $saved_filters['transition_post_status'] = $wp_filter['transition_post_status'];
1419 - unset($wp_filter['transition_post_status']);
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;
1420 770 }
1421 - if (isset($wp_filter['save_post'])) {
1422 - $saved_filters['save_post'] = $wp_filter['save_post'];
1423 - $wp_filter['save_post'] = new WP_Hook();
1424 - }
1425 - // Allow unlimited execution time for a potentially slow import.
1426 - set_time_limit(0);
1427 - global $mlsimport;
1428 771
1429 - // Log initial memory
1430 - $memStart = memory_get_usage(true);
1431 - $memStartMB = round($memStart / 1048576, 2);
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 + );
1432 794
1433 - // Unpack the per-task configuration (which statuses to keep, author/agent, post status).
1434 - $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
1435 - $newAuthor = intval($mlsimportItemOptionData['mlsimport_item_property_user']);
1436 - // Task has no assign-to-user set → fall back to the task's own author so listings are never saved with post_author 0.
1437 - if (0 === $newAuthor) {
1438 - $newAuthor = intval(get_post_field('post_author', $itemIdArray['item_id']));
1439 - }
1440 - $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
1441 - $useMlsAgent = ! empty($mlsimportItemOptionData['mlsimport_item_use_mls_agent']);
1442 - $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
1443 -
1444 - // Normalise the configured statuses to space-free enum keys for comparison.
1445 - if (is_array($mlsImportItemStatus)) {
1446 - $mlsImportItemStatus = array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus);
1447 - }
1448 -
1449 - // A listing with no ListingKey cannot be identified/imported → abort this item.
1450 - if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
1451 - $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
1452 - return;
1453 - }
1454 -
1455 - // Buffer any stray output produced during import (discarded later).
1456 - ob_start();
1457 -
1458 - // Identify the listing and the target post type.
1459 - $ListingKey = $property['ListingKey'];
1460 - $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
1461 -
1462 - // Memory before property ID lookup
1463 - $memBeforeRetrieve = memory_get_usage(true);
1464 -
1465 - // Look up whether this ListingKey already exists (0 if new).
1466 - $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
1467 -
1468 - // Memory after property ID lookup
1469 - $memAfterRetrieve = memory_get_usage(true);
1470 -
1471 - // Determine the live MLS status (StandardStatus, falling back to extra_meta MlsStatus).
1472 - $status = isset($property['StandardStatus']) ? mlsimport_normalize_status_enum($property['StandardStatus']) : mlsimport_normalize_status_enum($property['extra_meta']['MlsStatus']);
1473 -
1474 - $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
1475 -
1476 - // Decide insert vs not, based on existence + status match.
1477 - $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
1478 -
1479 - $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;
1480 - $this->writeImportLogs($log, $tipImport);
1481 -
1482 - $propertyHistory = [];
1483 - $content = $property['content'] ?? '';
1484 - $submitTitle = $ListingKey;
1485 -
1486 - // Memory before insert/update
1487 - $memBeforeInsert = memory_get_usage(true);
1488 -
1489 - $activityAction = '';
1490 -
1491 - // Incoming MLS modification time (unix). The hourly delta sync uses a
1492 - // rolling 2-hour overlap window, so the SAME listing is returned on
1493 - // several consecutive runs. We compare this against the last value we
1494 - // recorded (mlsimport_synced_mod) to count a listing as "edited" only
1495 - // when it actually changed since our last import — not on every re-touch.
1496 - $incomingMod = isset($property['extra_meta']['ModificationTimestamp'])
1497 - ? strtotime((string) $property['extra_meta']['ModificationTimestamp'])
1498 - : 0;
1499 - if (false === $incomingMod) {
1500 - $incomingMod = 0;
1501 - }
1502 -
1503 - // INSERT branch: create a brand-new property post.
1504 - if ($isInsert === 'yes') {
1505 - $post = [
1506 - 'post_title' => $submitTitle,
1507 - 'post_content' => $content,
1508 - 'post_status' => $propertyStatus,
1509 - 'post_type' => $listingPostType,
1510 - 'post_author' => $newAuthor,
1511 - ];
1512 -
1513 - $propertyId = wp_insert_post($post);
1514 -
1515 - // On failure log; on success stamp identifying meta + record the "added" action.
1516 - if (is_wp_error($propertyId)) {
1517 - $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1518 - } else {
1519 - update_post_meta($propertyId, 'ListingKey', $ListingKey);
1520 - update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1521 - $activityAction = 'added';
1522 - // Store the incoming modification time to seed later change detection.
1523 - update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1524 - $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1525 - mlsimport_telemetry_bump( 'imported' );
1526 - }
1527 -
1528 - clean_post_cache($propertyId);
1529 -
1530 - // Not inserting, but the property already exists → update-or-delete branch.
1531 - } elseif ($propertyId !== 0) {
1532 -
1533 -
1534 - // Memory before checking existing property
1535 - $memBeforeCheck = memory_get_usage(true);
1536 -
1537 - // Decide whether the existing listing's status still qualifies to be kept.
1538 - $keep = $this->shouldKeepExistingListing($status, $mlsImportItemStatus);
1539 -
1540 -
1541 - // Status no longer qualifies → delete the existing property.
1542 - if(!$keep){
1543 - $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;
1544 -
1545 - // Memory before delete
1546 - $memBeforeDelete = memory_get_usage(true);
1547 -
1548 - $this->deleteProperty($propertyId, $ListingKey);
1549 -
1550 - // Memory after delete
1551 - $memAfterDelete = memory_get_usage(true);
1552 -
1553 - $this->writeImportLogs($log, $tipImport);
1554 - } else {
1555 - // Status still qualifies → update the existing property in place.
1556 - // Memory before updating
1557 - $memBeforeUpdate = memory_get_usage(true);
1558 -
1559 - $propertyHistory = $this->updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1560 -
1561 - // Count as "edited" only when the listing actually changed since our
1562 - // last import. A missing incoming timestamp (0) means we can't tell,
1563 - // so fall back to recording the edit.
1564 - $storedMod = (int) get_post_meta($propertyId, 'mlsimport_synced_mod', true);
1565 - if (0 === $incomingMod || $incomingMod > $storedMod) {
1566 - $activityAction = 'edited';
1567 - if ($incomingMod > 0) {
1568 - update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1569 - }
1570 - }
1571 -
1572 - // Memory after updating
1573 - $memAfterUpdate = memory_get_usage(true);
1574 - }
1575 - }
1576 -
1577 - // Memory after insert/update
1578 - $memAfterInsert = memory_get_usage(true);
1579 -
1580 - // Guard: without a valid property id there is nothing further to process.
1581 - if ($propertyId === 0) {
1582 - $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1583 - return;
1584 - }
1585 -
1586 - // Memory before processing details
1587 - $memBeforeDetails = memory_get_usage(true);
1588 -
1589 - // Write meta/taxonomies/media/gallery/title for this property; returns the final title.
1590 - $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray, $isInsert, $useMlsAgent);
1591 -
1592 - if ( $activityAction !== '' ) {
1593 - // MLS # (RESO ListingId) and the raw status, snapshotted from the incoming feed
1594 - // so the history row is identifiable — and so a later delete can carry them forward.
1595 - $activityMlsId = (string) ( $property['adr_listingid'] ?? ( $property['ListingId'] ?? '' ) );
1596 - $activityStatus = (string) ( $property['StandardStatus'] ?? ( $property['extra_meta']['MlsStatus'] ?? '' ) );
1597 - mlsimport_record_activity( $activityAction, $propertyId, $ListingKey, isset($itemIdArray['item_id']) ? intval($itemIdArray['item_id']) : 0, $tipImport, $activityMlsId, $activityStatus );
1598 - }
1599 -
1600 - // Memory after processing details
1601 - $memAfterDetails = memory_get_usage(true);
1602 -
1603 - $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1604 - $this->writeImportLogs($log, $tipImport);
1605 -
1606 - clean_post_cache($propertyId);
1607 -
1608 - // More aggressive memory cleanup
1609 - // First clear specific large arrays in property data
1610 - // (element-by-element unset of the big sub-arrays before dropping them wholesale).
1611 - if (isset($property['Media']) && is_array($property['Media'])) {
1612 - foreach ($property['Media'] as $key => $media) {
1613 - unset($property['Media'][$key]);
1614 - }
1615 - }
1616 - if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1617 - foreach ($property['extra_meta'] as $key => $value) {
1618 - unset($property['extra_meta'][$key]);
1619 - }
1620 - }
1621 - if (isset($property['meta']) && is_array($property['meta'])) {
1622 - foreach ($property['meta'] as $key => $value) {
1623 - unset($property['meta'][$key]);
1624 - }
1625 - }
1626 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1627 - foreach ($property['taxonomies'] as $key => $value) {
1628 - unset($property['taxonomies'][$key]);
1629 - }
1630 - }
1631 -
1632 - // Then unset the main arrays
1633 - unset($property['Media']);
1634 - unset($property['extra_meta']);
1635 - unset($property['meta']);
1636 - unset($property['taxonomies']);
1637 - unset($property);
1638 -
1639 - // Clear any post caches that might have been created
1640 - clean_post_cache($propertyId);
1641 -
1642 - // Clear other variables that hold large data
1643 - unset($log);
1644 - unset($propertyHistory);
1645 - $GLOBALS['wpdb']->queries = array();
1646 -
1647 - // Clear WordPress specific caches
1648 - wp_cache_delete('get_term_meta', 'terms');
1649 - wp_cache_delete('terms', 'terms');
1650 - wp_cache_delete('term_meta', 'terms');
1651 - wp_cache_delete('get_terms', 'terms');
1652 -
1653 - // Clear post related caches
1654 - wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1655 - wp_cache_delete($propertyId, 'posts');
1656 -
1657 - // Force multiple garbage collection cycles
1658 - gc_collect_cycles();
1659 - gc_collect_cycles();
1660 -
1661 - // Close and discard any output buffer content
1662 - ob_end_clean();
1663 -
1664 - // Try to trigger PHP's internal memory cleanup
1665 - // (allocate then free 1MB to nudge the allocator).
1666 - $dummy = str_repeat('x', 1024 * 1024);
1667 - unset($dummy);
1668 -
1669 - // Final memory usage
1670 - $memEnd = memory_get_usage(true);
1671 - $memEndMB = round($memEnd / 1048576, 2);
1672 - $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1673 -
1674 - // If we see a significant memory increase, log a warning
1675 - // (threshold branch left empty — no warning currently emitted).
1676 - if ($memDiff > 5) {
1677 - }
1678 -
1679 - // Restore WordPress hooks
1680 - // (put back the transition_post_status / save_post callbacks saved earlier).
1681 - global $wp_filter;
1682 - if (!empty($saved_filters)) {
1683 - foreach ($saved_filters as $hook => $filter) {
1684 - $wp_filter[$hook] = $filter;
1685 - }
1686 - }
795 + return $this->stored_listing_write->write( $property, $settings );
1687 796 }
1688 797
1689 798
1690 799
@@ -1690,67 +799,10 @@
1690 799
1691 800
1692 801
1693 802
1694 - /**
1695 - * Check if the property should be inserted
1696 - *
1697 - * @param int $propertyId The property ID.
1698 - * @param string $status The property status.
1699 - * @param array $mlsImportItemStatus The MLS import item status.
1700 - * @param string $tipImport The import type.
1701 - * @return string 'yes' or 'no' indicating if the property should be inserted.
1702 - */
1703 - private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1704 - $this->writeImportLogs(
1705 - "Checking: on inserting {$propertyId}={$status} vs " .
1706 - json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1707 - $tipImport
1708 - );
1709 803
1710 -
1711 - // Already exists (id != 0) OR no status array configured → do not insert.
1712 - if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1713 - return 'no';
1714 -
1715 - }
1716 -
1717 - // Fallback set of statuses treated as "active" when no explicit array is given.
1718 - $activeStatuses = [
1719 - 'active',
1720 - 'active under contract',
1721 - 'active with contract',
1722 - 'activewithcontract',
1723 - 'status',
1724 - 'activeundercontract',
1725 - 'comingsoon',
1726 - 'coming soon',
1727 - 'pending'
1728 - ];
1729 - // Explicit status array configured → the live status must be in it.
1730 - if(is_array($mlsImportItemStatus)){
1731 - // Case-insensitive check against the configured statuses.
1732 - if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1733 - return 'no';
1734 - }
1735 -
1736 - // Cron path additionally requires a case-sensitive match.
1737 - if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1738 - return 'no';
1739 - }
1740 804
1741 - }else{
1742 - // No configured array → only insert recognised "active" statuses.
1743 - if(!in_array($status, $activeStatuses, true) ){
1744 - return 'no';
1745 - }
1746 - }
1747 -
1748 - // Passed all checks → insert.
1749 - return 'yes';
1750 - }
1751 -
1752 -
1753 805 /**
1754 806 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1755 807 * @param int $property_id
1756 808 * @param string|array $mlsImportItemStatus
@@ -1758,9 +810,10 @@
1758 810 */
1759 811 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1760 812
1761 813 // Resolve the taxonomy field-map, then read the property's current status term.
1762 - $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
814 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
815 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1763 816 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1764 817 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1765 818
1766 819 // Protected statuses: keep if property status matches
@@ -1801,9 +854,10 @@
1801 854 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
1802 855 : mlsimport_normalize_status_enum($mlsImportItemStatus);
1803 856
1804 857 // Resolve the taxonomy field-map, then read the property's current status term.
1805 - $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
858 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
859 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1806 860 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1807 861 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1808 862
1809 863 // An unreadable status is our read failing, not proof the listing should go — keep and log.
@@ -1825,24 +879,8 @@
1825 879 return false;
1826 880 }
1827 881
1828 882
1829 -/**
1830 - * Decide whether to keep an existing listing the (filtered) feed returned again.
1831 - *
1832 - * Uses the live MLS status — the SAME basis shouldInsertProperty() uses to
1833 - * decide an insert. The old code compared the stored property_status taxonomy
1834 - * term instead, which can be empty, remapped, or theme-labeled; when it did not
1835 - * equal the RESO status, keep said "delete" while insert said "yes", producing
1836 - * an add/delete/add cycle on every sync. See issue #152.
1837 - *
1838 - * @param string $status Live MLS StandardStatus (lowercased).
1839 - * @param array|string $mlsImportItemStatus Task's selected RESO statuses (lowercased).
1840 - * @return bool True to keep/update, false to delete.
1841 - */
1842 -public function shouldKeepExistingListing($status, $mlsImportItemStatus): bool {
1843 - return is_array($mlsImportItemStatus) && in_array($status, $mlsImportItemStatus, true);
1844 -}
1845 883
1846 884
1847 885
1848 886
@@ -1852,9 +890,10 @@
1852 890 * true we keep
1853 891 */
1854 892 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
1855 893 // Resolve the taxonomy field-map, then read the property's current status term.
1856 - $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
894 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
895 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1857 896 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1858 897 $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1859 898
1860 899 // The listing is still in the MLS feed. An unreadable local status is
@@ -1897,572 +936,13 @@
1897 936
1898 937
1899 938
1900 939
1901 - /**
1902 - * Update existing property
1903 - *
1904 - * @param int $propertyId The property ID.
1905 - * @param string $content The post content.
1906 - * @param string $listingPostType The listing post type.
1907 - * @param int $newAuthor The new author ID.
1908 - * @param string $status The property status.
1909 - * @param array $mlsImportItemStatus The MLS import item status.
1910 - * @param array $propertyHistory The property history.
1911 - * @param string $tipImport The import type.
1912 - * @param string $ListingKey The listing key.
1913 - * @return array Updated property history.
1914 - */
1915 - private function updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1916 940
1917 941
1918 - // Build the update payload (content/type/author; status handled elsewhere).
1919 - $post = [
1920 - 'ID' => $propertyId,
1921 - 'post_content' => $content,
1922 - 'post_type' => $listingPostType,
1923 - 'post_author' => $newAuthor,
1924 - ];
1925 942
1926 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1927 - $this->writeImportLogs($log, $tipImport);
1928 943
1929 - // Apply the update; log on error, otherwise append a history entry.
1930 - $propertyId = wp_update_post($post);
1931 - if (is_wp_error($propertyId)) {
1932 - $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1933 - } else {
1934 - $submitTitle = get_the_title($propertyId);
1935 - $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1936 - mlsimport_telemetry_bump( 'updated' );
1937 - }
1938 - clean_post_cache( $propertyId );
1939 -
1940 - return $propertyHistory;
1941 - }
1942 944
1943 -/**
1944 - * Process property details with memory tracking and optimization
1945 - *
1946 - * @param array $property The property data.
1947 - * @param int $propertyId The property ID.
1948 - * @param string $tipImport The import type.
1949 - * @param array $propertyHistory The property history.
1950 - * @param int $newAgent The new agent ID.
1951 - * @param array $itemIdArray The item ID array.
1952 - * @param string $isInsert If is a property insert
1953 - * @param bool $useMlsAgent Whether the task uses the feed's own listing agent.
1954 - */
1955 -private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert, $useMlsAgent) {
1956 - global $mlsimport, $wpdb;
1957 -
1958 -
1959 -
1960 - // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1961 - if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1962 - $timestampFields = [
1963 - 'StatusChangeTimestamp',
1964 - 'STELLAR_BOMDate',
1965 - 'PriceChangeTimestamp',
1966 - 'PhotosChangeTimestamp',
1967 - 'BridgeModificationTimestamp',
1968 - 'ModificationTimestamp',
1969 - 'OriginalEntryTimestamp',
1970 - 'MajorChangeTimestamp'
1971 - ];
1972 -
1973 - // Reformat each recognised timestamp field into a human-readable string.
1974 - foreach ($timestampFields as $tsField) {
1975 - if (!empty($property['extra_meta'][$tsField])) {
1976 - // Parse the raw value; only rewrite it when parsing succeeds.
1977 - $timestamp = strtotime($property['extra_meta'][$tsField]);
1978 - if ($timestamp !== false) {
1979 - $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1980 - }
1981 - }
1982 - }
1983 - }
1984 -
1985 -
1986 -
1987 - // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1988 - // This reduces memory by preventing DB auto-commits between operations
1989 - if (method_exists($wpdb, 'query')) {
1990 - $wpdb->query('SET autocommit = 0');
1991 - }
1992 -
1993 - // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1994 - $suspended_actions = [];
1995 - foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1996 - if (has_action($action)) {
1997 - $suspended_actions[$action] = true;
1998 - remove_all_actions($action);
1999 - }
2000 - }
2001 -
2002 - // Initial memory
2003 - $memStart = memory_get_usage(true);
2004 -
2005 - $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
2006 - $this->writeImportLogs($log, $tipImport);
2007 -
2008 - // 3. OPTIMIZE TAXONOMY PROCESSING
2009 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
2010 - $memBeforeTax = memory_get_usage(true);
2011 -
2012 - // Load taxonomy mapping options
2013 - $options = get_option('mlsimport_admin_fields_select');
2014 - $theme_schema = mlsimport_hardocde_theme_schema();
2015 - // Map of default-taxonomy-slug => user-chosen-taxonomy-slug.
2016 - $taxonomy_overrides = array();
2017 - if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
2018 - // For each user-mapped field, derive its default taxonomy from the schema.
2019 - foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
2020 - // Unmapped field → skip.
2021 - if ($mapped_tax === '') {
2022 - continue;
2023 - }
2024 - // Only fields that are taxonomy-typed in the schema contribute an override.
2025 - if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
2026 - $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
2027 - $default_tax = $theme_schema[$field_key]['name'];
2028 - // Record an override only when the mapping actually differs.
2029 - if ($default_tax !== $mapped_tax) {
2030 - $taxonomy_overrides[$default_tax] = $mapped_tax;
2031 - }
2032 - }
2033 - }
2034 - }
2035 -
2036 - // Theme-agnostic override. The local hardcoded schema above is the
2037 - // WPResidence mapping, so its default-taxonomy slugs do NOT match the
2038 - // taxonomies the server built when a different theme (e.g. Houzez) was
2039 - // used — the slug-based overrides then silently miss. For the core
2040 - // fields that also arrive with a top-level copy, locate the field's
2041 - // value inside the server-built taxonomies and redirect THAT taxonomy to
2042 - // the user's mapped one. Result: "map field -> Category" moves the value
2043 - // out of the server default and into the chosen taxonomy, on any theme.
2044 - $core_field_value_sources = array(
2045 - 'StandardStatus' => 'StandardStatus',
2046 - 'PropertyType' => 'adr_type',
2047 - 'City' => 'adr_city',
2048 - 'CountyOrParish' => 'adr_county',
2049 - );
2050 - if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
2051 - // For each core field, find which server-built taxonomy carries its value.
2052 - foreach ($core_field_value_sources as $reso_field => $top_level_key) {
2053 - $mapped_tax = isset($options['mls-fields-map-taxonomy'][$reso_field]) ? $options['mls-fields-map-taxonomy'][$reso_field] : '';
2054 - // Skip when unmapped or the top-level value is absent/empty.
2055 - if ($mapped_tax === '' || !isset($property[$top_level_key]) || '' === $property[$top_level_key]) {
2056 - continue;
2057 - }
2058 - // The field's value we expect to find inside a server taxonomy's terms.
2059 - $field_value = trim((string) $property[$top_level_key]);
2060 - // Scan each server taxonomy for a term equal to that value.
2061 - foreach ($property['taxonomies'] as $server_tax => $server_terms) {
2062 - // Already the target taxonomy → nothing to redirect.
2063 - if ($server_tax === $mapped_tax) {
2064 - continue;
2065 - }
2066 - // Normalise the server terms to a trimmed string list.
2067 - $term_list = is_array($server_terms) ? $server_terms : array($server_terms);
2068 - $term_list = array_map('trim', array_map('strval', $term_list));
2069 - // Value lives in this taxonomy → redirect it to the user's mapped taxonomy.
2070 - if (in_array($field_value, $term_list, true)) {
2071 - $taxonomy_overrides[$server_tax] = $mapped_tax;
2072 - }
2073 - }
2074 - }
2075 - }
2076 -
2077 - // Disable term counting temporarily (major memory saver)
2078 - wp_defer_term_counting(true);
2079 -
2080 - remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
2081 - wp_cache_delete('get_ancestors', 'taxonomy');
2082 -
2083 - // Clear existing taxonomies
2084 - $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
2085 -
2086 - // 4. PROCESS TAXONOMIES IN CHUNKS
2087 - $taxChunks = array_chunk($property['taxonomies'], 5, true);
2088 - foreach ($taxChunks as $taxChunk) {
2089 - // Assign each taxonomy's terms to the property.
2090 - foreach ($taxChunk as $taxonomy => $term) {
2091 - // Redirect to the user-mapped taxonomy when an override exists.
2092 - if (isset($taxonomy_overrides[$taxonomy])) {
2093 - $taxonomy = $taxonomy_overrides[$taxonomy];
2094 - }
2095 - wp_cache_delete("{$taxonomy}_term_counts", 'counts');
2096 - // Create/link the terms for this taxonomy on the property.
2097 - $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
2098 - $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
2099 -
2100 - // Memory cleanup after each taxonomy
2101 - wp_cache_delete('term_meta', 'terms');
2102 - wp_cache_delete($taxonomy, 'terms');
2103 - }
2104 -
2105 - // 5. FORCE GC AFTER EACH CHUNK
2106 - gc_collect_cycles();
2107 - }
2108 -
2109 - // Restore term filter and clean up
2110 - add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
2111 - delete_option('category_children');
2112 -
2113 - // Re-enable term counting
2114 - wp_defer_term_counting(false);
2115 -
2116 - $memAfterTax = memory_get_usage(true);
2117 - // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
2118 - }
2119 -
2120 - // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
2121 - // More targeted than wp_cache_flush()
2122 - wp_cache_delete('terms', 'terms');
2123 - wp_cache_delete('term_meta', 'terms');
2124 - wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
2125 - wp_cache_delete($propertyId, 'posts');
2126 -
2127 - // Prepare meta data
2128 - $property = $this->mlsimportSaasPrepareMetaForProperty($property);
2129 -
2130 - // 7. BATCH META UPDATES
2131 - if (isset($property['meta']) && is_array($property['meta'])) {
2132 - $memBeforeMeta = memory_get_usage(true);
2133 - $metaCount = count($property['meta']);
2134 -
2135 - // Use direct SQL for batch meta updates if many fields
2136 - if ($metaCount > 0 && method_exists($wpdb, 'prepare')) {
2137 - $meta_values = [];
2138 - // Normalise each meta value and build the delete+insert set.
2139 - foreach ($property['meta'] as $metaName => $metaValue) {
2140 - // Arrays become a comma-joined string; scalars get comma spacing normalised.
2141 - if (is_array($metaValue)) {
2142 - $metaValue = implode(', ', array_map('trim', $metaValue));
2143 - } else {
2144 - $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
2145 - }
2146 -
2147 - // Build history separately
2148 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
2149 -
2150 - // First delete existing
2151 - // (clear any prior row so the batch insert doesn't duplicate the key).
2152 - $wpdb->delete(
2153 - $wpdb->postmeta,
2154 - ['post_id' => $propertyId, 'meta_key' => $metaName],
2155 - ['%d', '%s']
2156 - );
2157 -
2158 - // Collect for batch insert
2159 - $meta_values[] = $wpdb->prepare(
2160 - "(%d, %s, %s)",
2161 - $propertyId,
2162 - $metaName,
2163 - $metaValue
2164 - );
2165 - }
2166 -
2167 - // Batch insert all meta at once
2168 - if (!empty($meta_values)) {
2169 - $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
2170 - implode(", ", $meta_values));
2171 - }
2172 - } else {
2173 - // Dead code - left intentianaly
2174 - // Standard approach for fewer meta fields
2175 - foreach ($property['meta'] as $metaName => $metaValue) {
2176 - if (is_array($metaValue)) {
2177 - $metaValue = implode(', ', array_map('trim', $metaValue));
2178 - } else {
2179 - $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
2180 - }
2181 - update_post_meta($propertyId, $metaName, $metaValue);
2182 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
2183 - }
2184 - }
2185 -
2186 - $memAfterMeta = memory_get_usage(true);
2187 - // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
2188 - }
2189 -
2190 - // Extra meta processing
2191 - // (theme adapter writes provider-specific extra_meta and may add history rows).
2192 - $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
2193 - if (isset($extraMetaResult['property_history'])) {
2194 - $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
2195 - }
2196 -
2197 - // 8. PROCESS MEDIA IN CHUNKS
2198 - $memBeforeMedia = memory_get_usage(true);
2199 -
2200 -
2201 - // Only handle media when the payload carries a Media array.
2202 - if (isset($property['Media']) && is_array($property['Media'])) {
2203 - // Accumulator of newly-attached image ids for the gallery.
2204 - $media_attachments=array();
2205 -
2206 - $mediaCount = count($property['Media']);
2207 -
2208 - // Detect if media has changed for existing properties
2209 - // (on insert we always attach; on edit we only refresh when images differ).
2210 - $shouldRefreshMedia = false;
2211 - if ($isInsert === 'no') {
2212 - $shouldRefreshMedia = $this->hasMediaChanged($propertyId, $property['Media']);
2213 - // Changed → delete our old MLS attachments so they can be rebuilt.
2214 - if ($shouldRefreshMedia) {
2215 - $this->writeImportLogs('Media changed for property ' . $propertyId . ', refreshing ' . $mediaCount . ' images', $tipImport);
2216 - $this->deleteExistingMlsAttachments($propertyId);
2217 - } else {
2218 - // Unchanged → leave existing images untouched.
2219 - $this->writeImportLogs('Media unchanged for property ' . $propertyId . ', skipping image refresh', $tipImport);
2220 - }
2221 - }
2222 -
2223 - // Sort media by Order field if it exists
2224 - // (orders the images ascending so galleries follow the feed's sequence).
2225 - if (isset($property['Media'][0]['Order'])) {
2226 - $order = array_column($property['Media'], 'Order');
2227 - array_multisort($order, SORT_ASC, $property['Media']);
2228 - }
2229 -
2230 - // Process in chunks of 5
2231 - $mediaChunks = array_chunk($property['Media'], 5,true);
2232 - $mediaHistoryParts = [];
2233 -
2234 - // Clear original array to free memory
2235 - $originalMedia = $property['Media'];
2236 -
2237 - // Find featured image in single loop
2238 - // (choose which media item becomes the post thumbnail).
2239 - $featuredImageKey = null;
2240 - $orderOneKey = null;
2241 -
2242 - // First priority: Look for PreferredPhotoYN = 1
2243 - foreach ($property['Media'] as $key => $mediaItem) {
2244 - // Priority 1: PreferredPhotoYN = 1 (immediate selection)
2245 - if (isset($mediaItem['PreferredPhotoYN']) && $mediaItem['PreferredPhotoYN'] == 1) {
2246 - $featuredImageKey = $key;
2247 - break;
2248 - }
2249 -
2250 -
2251 - // Priority 2: Store Order = 1 key for potential use
2252 - if ($orderOneKey === null && isset($mediaItem['Order']) && $mediaItem['Order'] == 1) {
2253 - $orderOneKey = $key;
2254 - }
2255 -
2256 - }
2257 -
2258 - // No preferred photo found → fall back to the Order == 1 image.
2259 - if ($featuredImageKey === null && $orderOneKey !== null) {
2260 - $featuredImageKey = $orderOneKey;
2261 - }
2262 -
2263 - // Use Order = 1 image if no preferred image was found
2264 - // (duplicate of the check above — same effect).
2265 - if ($featuredImageKey === null && $orderOneKey !== null) {
2266 - $featuredImageKey = $orderOneKey;
2267 - }
2268 -
2269 - // Priority 3: Use first image if nothing else found
2270 - if ($featuredImageKey === null && !empty($property['Media'])) {
2271 - $featuredImageKey = 0;
2272 - }
2273 -
2274 -
2275 - // Drop the source Media array (chunks already captured above).
2276 - unset($property['Media']);
2277 -
2278 - // On insert or media refresh, clear the theme gallery meta before rebuilding it.
2279 - if ($isInsert !== 'no' || $shouldRefreshMedia) {
2280 - delete_post_meta($propertyId, 'fave_property_images');
2281 - delete_post_meta($propertyId, 'REAL_HOMES_property_images');
2282 - delete_post_meta($propertyId, 'wpestate_property_gallery');
2283 - }
2284 -
2285 -
2286 - // Attach each chunk of images, threading the growing $media_attachments list.
2287 - foreach ($mediaChunks as $index => $mediaChunk) {
2288 - $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia);
2289 - // $mediaHistoryParts[] = $chunkHistory;
2290 -
2291 - // Free memory
2292 - unset($mediaChunk);
2293 - //unset($chunkHistory);
2294 - gc_collect_cycles();
2295 -
2296 - // Incremental progress report
2297 - }
2298 -
2299 -
2300 - // Only rewrite the gallery when we actually (re)built the attachment list
2301 - // (insert or media refresh). On the unchanged-media path $media_attachments
2302 - // is empty, and overwriting would wipe the existing gallery.
2303 - if ($isInsert !== 'no' || $shouldRefreshMedia) {
2304 - // Persist the rebuilt attachment id list as the theme gallery meta.
2305 - $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
2306 -
2307 - // Guarantee a featured image. When the feed has no preferred photo
2308 - // (PreferredPhotoYN) and no Order == 1 image — as with AMPRE/PropTx,
2309 - // whose PreferredPhotoYN is empty and Order is not 1-based — fall back
2310 - // to the first attached image so the property always has a thumbnail.
2311 - if ( ! empty( $media_attachments ) && ! has_post_thumbnail( $propertyId ) ) {
2312 - set_post_thumbnail( $propertyId, reset( $media_attachments ) );
2313 - }
2314 - }
2315 -
2316 - // Combine all chunks
2317 - // $mediaHistory = implode('</br>', $mediaHistoryParts);
2318 - // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2319 -
2320 - // Clean up
2321 - unset($mediaChunks);
2322 - unset($mediaHistoryParts);
2323 - unset($mediaHistory);
2324 - unset($originalMedia);
2325 - } else {
2326 - // No Media array on the property → fallback call with an empty media set.
2327 - $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert,$featuredImageKey);
2328 - $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2329 - }
2330 -
2331 -
2332 - $memAfterMedia = memory_get_usage(true);
2333 - // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
2334 -
2335 - // Update title
2336 - // (recompute the post title from the template now that meta is in place).
2337 - $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
2338 - $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
2339 -
2340 - // Correlation update
2341 - // (theme adapter wires up agent/user correlation; $useMlsAgent controls source).
2342 - $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, ['use_mls_agent' => $useMlsAgent], $newAgent);
2343 -
2344 - // 9. COMMIT TRANSACTION
2345 - if (method_exists($wpdb, 'query')) {
2346 - $wpdb->query('COMMIT');
2347 - $wpdb->query('SET autocommit = 1');
2348 - }
2349 -
2350 - // Save property history - using direct SQL if history is large
2351 - if (!empty($propertyHistory)) {
2352 - // Only persist history when the history option is enabled (defaults to 1).
2353 - if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
2354 - $propertyHistory[] = '---------------------------------------------------------------</br>';
2355 - // Flatten the history rows into a single HTML string.
2356 - $propertyHistory = implode('</br>', $propertyHistory);
2357 -
2358 - // 10. USE DIRECT SQL FOR LARGE HISTORY
2359 - // (large blobs bypass update_post_meta to avoid its overhead).
2360 - if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
2361 - $wpdb->update(
2362 - $wpdb->postmeta,
2363 - ['meta_value' => $propertyHistory],
2364 - ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
2365 - ['%s'],
2366 - ['%d', '%s']
2367 - );
2368 - } else {
2369 - update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
2370 - }
2371 - }
2372 - }
2373 -
2374 - // 11. RESTORE ACTIONS
2375 - // (the add/remove pair below merely re-marks the previously-suspended actions).
2376 - if (!empty($suspended_actions)) {
2377 - foreach ($suspended_actions as $action => $true) {
2378 - add_action($action, '_wp_action_exists_' . $action);
2379 - remove_action($action, '_wp_action_exists_' . $action);
2380 - }
2381 - }
2382 -
2383 - // 12. FINAL CLEANUP
2384 - $property = null;
2385 - $propertyHistory = null;
2386 - wp_cache_flush();
2387 - gc_collect_cycles();
2388 -
2389 - // Final memory stats
2390 - $memEnd = memory_get_usage(true);
2391 -
2392 - return $newTitle;
2393 -}
2394 -
2395 -
2396 -/**
2397 - * Check if incoming MLS media differs from existing MLS-imported attachments.
2398 - *
2399 - * Compares incoming MediaURL values against the GUIDs of existing attachments
2400 - * that have the is_mlsimport meta flag. Uses ID-only queries for memory efficiency.
2401 - *
2402 - * @param int $propertyId The property post ID.
2403 - * @param array $incomingMedia Array of media items, each with a 'MediaURL' key.
2404 - * @return bool True if images need refresh, false if unchanged.
2405 - */
2406 -private function hasMediaChanged($propertyId, $incomingMedia) {
2407 - // Fetch the ids of the property's existing MLS-imported attachments.
2408 - $existing = get_posts([
2409 - 'post_type' => 'attachment',
2410 - 'post_parent' => $propertyId,
2411 - 'post_status' => 'inherit',
2412 - 'meta_key' => 'is_mlsimport',
2413 - 'meta_value' => 1,
2414 - 'fields' => 'ids',
2415 - 'numberposts' => -1,
2416 - ]);
2417 -
2418 - // Map existing attachments to their source URLs (stored in the guid).
2419 - $existingUrls = array_map(function ($id) {
2420 - return get_post_field('guid', $id);
2421 - }, $existing);
2422 -
2423 - // Collect the incoming MediaURL values (dropping empties).
2424 - $incomingUrls = array_filter(array_column($incomingMedia, 'MediaURL'));
2425 -
2426 - // Sort both lists so the comparison is order-independent.
2427 - sort($existingUrls);
2428 - sort($incomingUrls);
2429 -
2430 - // Different URL sets → media changed.
2431 - return $existingUrls !== $incomingUrls;
2432 -}
2433 -
2434 -
2435 -/**
2436 - * Delete all MLS-imported attachments for a property.
2437 - *
2438 - * Only deletes attachments that have the is_mlsimport post meta set to 1.
2439 - * Manually uploaded attachments are preserved.
2440 - *
2441 - * @param int $propertyId The property post ID.
2442 - */
2443 -private function deleteExistingMlsAttachments($propertyId) {
2444 - // Fetch only attachments flagged as MLS-imported (is_mlsimport = 1).
2445 - $mlsAttachments = get_posts([
2446 - 'post_type' => 'attachment',
2447 - 'post_parent' => $propertyId,
2448 - 'post_status' => 'inherit',
2449 - 'meta_key' => 'is_mlsimport',
2450 - 'meta_value' => 1,
2451 - 'fields' => 'ids',
2452 - 'numberposts' => -1,
2453 - ]);
2454 -
2455 - // Force-delete each one (removes the underlying file too).
2456 - foreach ($mlsAttachments as $attachId) {
2457 - wp_delete_post($attachId, true);
2458 - }
2459 -
2460 - // The old featured image is one of the attachments we just deleted, so the
2461 - // stale _thumbnail_id now points at nothing. Clear it — otherwise
2462 - // has_post_thumbnail() stays true and the fresh featured image is never set.
2463 - delete_post_thumbnail($propertyId);
2464 -}
2465 945
2466 946
2467 947
2468 948 }