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 +129 -29 7.1.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.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.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' );
@@ -229,8 +229,52 @@
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 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';
233 277 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-wordpress-environment.php';
234 278 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-write.php';
235 279 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-adapter-factory.php';
236 280 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
@@ -249,15 +293,23 @@
249 293 require_once plugin_dir_path( __FILE__ ) . 'enviroment/CentrisResoClass.php';
250 294 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ProviderResoClasses.php';
251 295 require_once plugin_dir_path( __FILE__ ) . 'enviroment/UnsupportedResoClass.php';
252 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';
253 300 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
254 301
255 302 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
256 303 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
257 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';
258 308 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-autotrigger.php';
259 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';
260 312 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-activity-log.php';
261 313 // #208: internal incident alerts (dedup + resolve) and import/connection health watch.
262 314 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-alerts.php';
263 315 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-import-health.php';
@@ -372,8 +424,10 @@
372 424 10,
373 425 3
374 426 );
375 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' ) );
376 430 add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
377 431 // Trash/unpublish (not a permanent delete) must also drop the listings row, so the
378 432 // search index only ever holds published listings.
379 433 add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
@@ -404,9 +458,11 @@
404 458 * Optimizations:
405 459 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
406 460 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
407 461 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
408 - * - 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
409 465 *
410 466 * @return void
411 467 */
412 468 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
@@ -465,29 +521,31 @@
465 521 //error_log('[AutoCron] No token, exiting.');
466 522 return;
467 523 }
468 524
469 - // 2. Check if MLS connection is valid - exit if not
470 - $is_mls_connected = get_option('mlsimport_connection_test', '');
471 - //error_log('[AutoCron] After connection check: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
472 - if ('yes' !== $is_mls_connected) {
473 - // Same rule as the token exit above: a sync attempt that cannot run
474 - // records why, so the heartbeat can surface it.
475 - mlsimport_telemetry_set( 'last_sync_failed', time() );
476 - mlsimport_telemetry_set( 'last_sync_failed_code', 'mls_not_connected' );
477 - //error_log('[AutoCron] No valid connection, exiting.');
478 - return;
479 - }
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.
480 529
481 530 // Claim the run lock now that we are committed to processing.
482 531 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
483 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 {
484 538 // Heartbeat (#208): record that a cron import is now running, so the next
485 539 // cron entry can tell a clean finish from a process that died mid-loop.
486 540 mlsimport_cron_heartbeat_start();
487 541
488 - // Record sync attempt in telemetry
489 - 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.
490 548 mlsimport_telemetry_set( 'last_sync_attempt', time() );
491 549
492 550 // 3. Set batch size for gathering and initialize loop variables
493 551 $batch_size = 100;
@@ -535,21 +593,53 @@
535 593 } while (true);
536 594
537 595 // 5. Order by starvation (issue #203): the query above returns tasks in
538 596 // the same fixed order every hour, so when an early large task ate the
539 - // whole cycle the bottom tasks were skipped run after run. Sorting by the
540 - // 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.
541 601 $cron_task_watermarks = array();
542 602 foreach ($cron_task_ids as $prop_id) {
543 - $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 + );
544 607 }
545 608 unset($cron_task_ids);
546 609
547 - // 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();
548 614 foreach (mlsimport_cron_task_order($cron_task_watermarks) as $prop_id) {
549 615 $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
550 616 mlsimport_debuglogs_per_plugin($logs);
551 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 +
552 642 // Call processing function for this item. The feed count it pulls
553 643 // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
554 644 $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
555 645
@@ -565,13 +655,16 @@
565 655 }
566 656 }
567 657
568 658 // Heartbeat (#208): clean finish — also resolves an open died-run incident.
569 - 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 + }
570 666
571 - // Release the run lock so the next scheduled run can proceed.
572 - delete_transient( 'mlsimport_cron_running' );
573 -
574 667 // last_sync_success is no longer stamped here (issue #207 finding 1): the
575 668 // end-of-loop stamp reported success even when every request failed, and
576 669 // never fired when a run died mid-loop. Each listings request now records
577 670 // its own outcome inside mlsimport_make_listing_requests().
@@ -908,12 +1001,12 @@
908 1001 // next to mlsimport_resolve_theme_id() whose answer it renders (#242).
909 1002
910 1003
911 1004
912 -// mlsimport_save_account_callback() (wp_ajax_mlsimport_save_account) moved to
913 -// includes/mlsimport-onboarding.php next to its sibling handler
914 -// mlsimport_ajax_test_account_connection(), so both credential-save paths live
915 -// 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.
916 1009
917 1010
918 1011
919 1012
@@ -921,8 +1014,11 @@
921 1014
922 1015 add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
923 1016 function mlsimport_save_mls_data_callback() {
924 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 + }
925 1021
926 1022 $options = get_option('mlsimport_admin_options', []);
927 1023 $options = is_array( $options ) ? $options : array();
928 1024 $previous_mls_id = isset( $options['mlsimport_mls_name'] )
@@ -944,10 +1040,14 @@
944 1040 Mlsimport_Provider_Family::clear_active_state();
945 1041 } else {
946 1042 Mlsimport_Provider_Family::clear_access_tokens();
947 1043 delete_option( 'mlsimport_connection_test' );
948 - delete_option( 'mlsimport_mls_metadata_populated' );
949 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 );
950 1050
951 1051 update_option('mlsimport_admin_options', $options);
952 1052
953 1053 // Always test the newly saved selection. Reusing a prior "yes" flag could