PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
← All changes | mlsimport.php +130 -29 7.17.2.1 View file →
@@ -2,9 +2,9 @@
2 2 /**
3 3 * Plugin Name: MlsImport
4 4 * Plugin URI: https://mlsimport.com/
5 5 * Description: MLS Import - The MLSImport plugin facilitates the connection to your real estate MLS database, allowing you to download and synchronize real estate property data from the MLS.
6 - * Version: 7.1
6 + * Version: 7.2.1
7 7 * Requires at least: 5.2
8 8 * Requires PHP: 7.4
9 9 * License: GPLv3
10 10 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
@@ -39,9 +39,9 @@
39 39 }
40 40
41 41
42 42 // Current plugin version (kept in sync with the header above and the readme).
43 -define( 'MLSIMPORT_VERSION', '7.1');
43 +define( 'MLSIMPORT_VERSION', '7.2.1');
44 44 // Marketing/portal host used to build sign-up and affiliate links.
45 45 define( 'MLSIMPORT_CLUBLINK', 'mlsimport.com' );
46 46 // Scheme for the portal host links.
47 47 define( 'MLSIMPORT_CLUBLINKSSL', 'https' );
@@ -228,8 +228,53 @@
228 228 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-status-normalize.php';
229 229 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-fields.php';
230 230 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-title.php';
231 231 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-media.php';
232 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-listing-key-migration.php';
233 +// Multi-MLS connection registry + the one-time single->multi MLS migration
234 +// (the migration file hooks itself on init, after the listing-key migration).
235 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-connections.php';
236 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-multimls-migration.php';
237 +// Per-connection option resolution + settings export/import transfer (#275):
238 +// every read/write of the "_{mls_id}"-suffixed per-MLS state goes through these.
239 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connection-options.php';
240 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-settings-transfer.php';
241 +// N-entitlement SaaS contract consumer (#276): entitlements parsing, the
242 +// mls_id echo guard, and the stable not_entitled rejection handling.
243 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-entitlements.php';
244 +// Import task connection binding + cron isolation (#277): a task belongs to
245 +// ONE connection for life; the hourly cron gates each task on ITS connection.
246 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-binding.php';
247 +// Per-connection reconciliation (#279): the daily event runs the untouched
248 +// decision module once per connection, each with an echo-guarded scoped
249 +// snapshot and an inventory limited to that connection's stamped listings.
250 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-reconciliation-connections.php';
251 +// Connections screen (#280): the settings-page Connections tab — screen data
252 +// (registry rows + activity + plan cap + account state) and its AJAX handlers
253 +// (drag-reorder priority, per-row connection test, account disconnect).
254 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-screen.php';
255 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-ajax.php';
256 +// Add-MLS drawer (#281): connection-scoped metadata gather (shared with the
257 +// admin metadata AJAX) + the drawer's refuse/test/register/seed flow.
258 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-gather.php';
259 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-add.php';
260 +// The follow-up field-mapping seed the drawer posts after a saved add (#325).
261 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-seed.php';
262 +// Edit-connection drawer (tab consolidation): the retired "MLS Connection"
263 +// credentials tab's replacement — refuse/test/store flow that updates a
264 +// registered connection's credentials (and mirrors the current connection's
265 +// into the flat options).
266 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-edit.php';
267 +// Remove-connection flow: a row's Remove action — drops the record + its
268 +// per-connection options and promotes the next-priority connection when the
269 +// current one was removed. Imported listings and tasks stay.
270 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-remove.php';
271 +// Dedupe mechanics (#282): one visible copy per physical address across
272 +// connections — winner by priority, loser hidden (never deleted), promoted
273 +// back automatically when the winner disappears. Address normalization is
274 +// the pure half; the evaluator/hooks half depends on it.
275 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-dedupe-address.php';
276 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-dedupe.php';
232 277 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-wordpress-environment.php';
233 278 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-write.php';
234 279 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-adapter-factory.php';
235 280 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
@@ -248,15 +293,23 @@
248 293 require_once plugin_dir_path( __FILE__ ) . 'enviroment/CentrisResoClass.php';
249 294 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ProviderResoClasses.php';
250 295 require_once plugin_dir_path( __FILE__ ) . 'enviroment/UnsupportedResoClass.php';
251 296 require_once plugin_dir_path( __FILE__ ) . 'includes/addons/agents_offices.php';
297 +// Why the last SaaS login failed (no subscription vs bad password) and the
298 +// one "not connected" box every screen prints.
299 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-account-status.php';
252 300 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
253 301
254 302 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
255 303 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
256 304 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-progressive-save.php';
305 +// Per-connection field-mapping UI: the one scope-resolution rule shared by the
306 +// field_options tab, the field-configuration AJAX, and the metadata-gather AJAX.
307 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-mapping-scope.php';
257 308 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-autotrigger.php';
258 309 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-telemetry.php';
310 +// #283: per-connection telemetry (sync stamps + the connections payload seam).
311 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-telemetry-connections.php';
259 312 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-activity-log.php';
260 313 // #208: internal incident alerts (dedup + resolve) and import/connection health watch.
261 314 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-alerts.php';
262 315 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-import-health.php';
@@ -371,8 +424,10 @@
371 424 10,
372 425 3
373 426 );
374 427 add_action( 'admin_init', array( 'Mlsimport_Standalone_Table', 'maybe_upgrade' ) );
428 +// One-time repair of comma-glued taxonomy terms written before 7.1.2 (#290).
429 +add_action( 'admin_init', array( 'Mlsimport_Standalone_Cpt', 'maybe_split_packed_terms' ) );
375 430 add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
376 431 // Trash/unpublish (not a permanent delete) must also drop the listings row, so the
377 432 // search index only ever holds published listings.
378 433 add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
@@ -403,9 +458,11 @@
403 458 * Optimizations:
404 459 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
405 460 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
406 461 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
407 - * - Skips processing if MLS is not connected or token is missing
462 + * - Bails only when the SaaS token is missing (global, one account); each
463 + * task is then gated on its OWN connection (#277) — a broken or deleted
464 + * connection skips its tasks while healthy connections keep importing
408 465 *
409 466 * @return void
410 467 */
411 468 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
@@ -464,29 +521,31 @@
464 521 //error_log('[AutoCron] No token, exiting.');
465 522 return;
466 523 }
467 524
468 - // 2. Check if MLS connection is valid - exit if not
469 - $is_mls_connected = get_option('mlsimport_connection_test', '');
470 - //error_log('[AutoCron] After connection check: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
471 - if ('yes' !== $is_mls_connected) {
472 - // Same rule as the token exit above: a sync attempt that cannot run
473 - // records why, so the heartbeat can surface it.
474 - mlsimport_telemetry_set( 'last_sync_failed', time() );
475 - mlsimport_telemetry_set( 'last_sync_failed_code', 'mls_not_connected' );
476 - //error_log('[AutoCron] No valid connection, exiting.');
477 - return;
478 - }
525 + // 2. The connection-status check is no longer a global bail-out (#277):
526 + // each task is gated on ITS OWN connection inside the loop below, so one
527 + // broken MLS never blocks tasks on healthy connections. Only the SaaS
528 + // token above stays global — one account, one token.
479 529
480 530 // Claim the run lock now that we are committed to processing.
481 531 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
482 532
533 + // Every operation after lock acquisition belongs to one committed cron
534 + // run. Keep that complete run inside a try/finally boundary so a Throwable
535 + // from task discovery, connection gating, or one Import Run cannot leave
536 + // the site locked until the transient TTL expires (#303).
537 + try {
483 538 // Heartbeat (#208): record that a cron import is now running, so the next
484 539 // cron entry can tell a clean finish from a process that died mid-loop.
485 540 mlsimport_cron_heartbeat_start();
486 541
487 - // Record sync attempt in telemetry
488 - mlsimport_telemetry_bump( 'syncs' );
542 + // Record sync attempt in telemetry. The 'syncs' counter is no longer
543 + // bumped here (#283): a run-level tick belongs to no single connection
544 + // and would double-count against the per-pull tick that
545 + // mlsimport_telemetry_record_sync_result() now records — one pull, one
546 + // tick, attributed to the pull's own connection, so the global syncs sum
547 + // stays equal to the sum of the per-connection buckets.
489 548 mlsimport_telemetry_set( 'last_sync_attempt', time() );
490 549
491 550 // 3. Set batch size for gathering and initialize loop variables
492 551 $batch_size = 100;
@@ -534,21 +593,53 @@
534 593 } while (true);
535 594
536 595 // 5. Order by starvation (issue #203): the query above returns tasks in
537 596 // the same fixed order every hour, so when an early large task ate the
538 - // whole cycle the bottom tasks were skipped run after run. Sorting by the
539 - // last-sync watermark puts the longest-unsynced region first in line.
597 + // whole cycle the bottom tasks were skipped run after run. The queue key
598 + // is the newer of last success and last ATTEMPT (issue #330): ordering
599 + // by success alone let one task whose run never completes keep the
600 + // oldest stamp and hold first place every hour while the rest starved.
540 601 $cron_task_watermarks = array();
541 602 foreach ($cron_task_ids as $prop_id) {
542 - $cron_task_watermarks[ $prop_id ] = (string) get_post_meta( $prop_id, 'mlsimport_last_date', true );
603 + $cron_task_watermarks[ $prop_id ] = mlsimport_cron_task_queue_key(
604 + (string) get_post_meta( $prop_id, 'mlsimport_last_date', true ),
605 + (string) get_post_meta( $prop_id, 'mlsimport_last_attempt', true )
606 + );
543 607 }
544 608 unset($cron_task_ids);
545 609
546 - // 6. Process every task, most starved first
610 + // 6. Process every task, most starved first. A broken connection is
611 + // recorded ONCE per run, not once per task — the entry is identical and
612 + // re-writing telemetry for every skipped task would only amplify writes.
613 + $failed_connections = array();
547 614 foreach (mlsimport_cron_task_order($cron_task_watermarks) as $prop_id) {
548 615 $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
549 616 mlsimport_debuglogs_per_plugin($logs);
550 617
618 + // Per-connection failure isolation (#277): gate this task on ITS OWN
619 + // connection. A broken connection's tasks skip with a recorded reason;
620 + // a DELETED connection's tasks skip with a deduplicated health
621 + // incident and are never re-defaulted; healthy connections keep
622 + // importing in this same run.
623 + $mlsimport_gate = mlsimport_cron_task_gate( (int) $prop_id );
624 + if ( 'import' !== $mlsimport_gate['action'] ) {
625 + if ( 'skip_missing' === $mlsimport_gate['action'] ) {
626 + mlsimport_alert_open(
627 + 'task_connection_missing:' . (int) $prop_id,
628 + 'task_connection_missing',
629 + array( 'task_id' => (int) $prop_id, 'mls_id' => $mlsimport_gate['mls_id'] )
630 + );
631 + } elseif ( ! isset( $failed_connections[ $mlsimport_gate['mls_id'] ] ) ) {
632 + $failed_connections[ $mlsimport_gate['mls_id'] ] = true;
633 + mlsimport_record_connection_sync_failure( $mlsimport_gate['mls_id'], $mlsimport_gate['code'] );
634 + }
635 + mlsimport_debuglogs_per_plugin(
636 + 'Task ' . $prop_id . ' skipped: connection ' . $mlsimport_gate['mls_id']
637 + . ' ' . $mlsimport_gate['action'] . ' (' . $mlsimport_gate['code'] . ')' . PHP_EOL
638 + );
639 + continue;
640 + }
641 +
551 642 // Call processing function for this item. The feed count it pulls
552 643 // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
553 644 $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
554 645
@@ -564,13 +655,16 @@
564 655 }
565 656 }
566 657
567 658 // Heartbeat (#208): clean finish — also resolves an open died-run incident.
568 - mlsimport_cron_heartbeat_finish();
659 + } finally {
660 + // A failed task still ends this cron invocation. Mark the heartbeat as
661 + // finished and release the overlap lock while PHP continues propagating
662 + // the original Throwable to the caller for logging and diagnosis.
663 + mlsimport_cron_heartbeat_finish();
664 + delete_transient( 'mlsimport_cron_running' );
665 + }
569 666
570 - // Release the run lock so the next scheduled run can proceed.
571 - delete_transient( 'mlsimport_cron_running' );
572 -
573 667 // last_sync_success is no longer stamped here (issue #207 finding 1): the
574 668 // end-of-loop stamp reported success even when every request failed, and
575 669 // never fired when a run died mid-loop. Each listings request now records
576 670 // its own outcome inside mlsimport_make_listing_requests().
@@ -907,12 +1001,12 @@
907 1001 // next to mlsimport_resolve_theme_id() whose answer it renders (#242).
908 1002
909 1003
910 1004
911 -// mlsimport_save_account_callback() (wp_ajax_mlsimport_save_account) moved to
912 -// includes/mlsimport-onboarding.php next to its sibling handler
913 -// mlsimport_ajax_test_account_connection(), so both credential-save paths live
914 -// together and are covered by tests/unit/save-account-token-purge-test.php.
1005 +// mlsimport_save_account_callback() (wp_ajax_mlsimport_save_account) lives in
1006 +// includes/mlsimport-onboarding.php with the wizard code that submits it. This
1007 +// also keeps the live credential-save path loadable by the focused credential
1008 +// sanitization and token-purge regression harnesses.
915 1009
916 1010
917 1011
918 1012
@@ -920,8 +1014,11 @@
920 1014
921 1015 add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
922 1016 function mlsimport_save_mls_data_callback() {
923 1017 check_ajax_referer('mlsimport_onboarding_nonce', 'security');
1018 + if ( ! current_user_can( 'manage_options' ) ) {
1019 + wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
1020 + }
924 1021
925 1022 $options = get_option('mlsimport_admin_options', []);
926 1023 $options = is_array( $options ) ? $options : array();
927 1024 $previous_mls_id = isset( $options['mlsimport_mls_name'] )
@@ -943,10 +1040,14 @@
943 1040 Mlsimport_Provider_Family::clear_active_state();
944 1041 } else {
945 1042 Mlsimport_Provider_Family::clear_access_tokens();
946 1043 delete_option( 'mlsimport_connection_test' );
947 - delete_option( 'mlsimport_mls_metadata_populated' );
948 1044 }
1045 +
1046 + // Saving credentials always forces a fresh metadata gather for the SAVED
1047 + // MLS. The populated flag is per-connection (#275): only this connection's
1048 + // flag is cleared — other connections' gathered state stays isolated.
1049 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
949 1050
950 1051 update_option('mlsimport_admin_options', $options);
951 1052
952 1053 // Always test the newly saved selection. Reusing a prior "yes" flag could