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 +127 -29 7.1.27.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.2
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.2');
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';
@@ -406,9 +458,11 @@
406 458 * Optimizations:
407 459 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
408 460 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
409 461 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
410 - * - 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
411 465 *
412 466 * @return void
413 467 */
414 468 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
@@ -467,29 +521,31 @@
467 521 //error_log('[AutoCron] No token, exiting.');
468 522 return;
469 523 }
470 524
471 - // 2. Check if MLS connection is valid - exit if not
472 - $is_mls_connected = get_option('mlsimport_connection_test', '');
473 - //error_log('[AutoCron] After connection check: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
474 - if ('yes' !== $is_mls_connected) {
475 - // Same rule as the token exit above: a sync attempt that cannot run
476 - // records why, so the heartbeat can surface it.
477 - mlsimport_telemetry_set( 'last_sync_failed', time() );
478 - mlsimport_telemetry_set( 'last_sync_failed_code', 'mls_not_connected' );
479 - //error_log('[AutoCron] No valid connection, exiting.');
480 - return;
481 - }
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.
482 529
483 530 // Claim the run lock now that we are committed to processing.
484 531 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
485 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 {
486 538 // Heartbeat (#208): record that a cron import is now running, so the next
487 539 // cron entry can tell a clean finish from a process that died mid-loop.
488 540 mlsimport_cron_heartbeat_start();
489 541
490 - // Record sync attempt in telemetry
491 - 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.
492 548 mlsimport_telemetry_set( 'last_sync_attempt', time() );
493 549
494 550 // 3. Set batch size for gathering and initialize loop variables
495 551 $batch_size = 100;
@@ -537,21 +593,53 @@
537 593 } while (true);
538 594
539 595 // 5. Order by starvation (issue #203): the query above returns tasks in
540 596 // the same fixed order every hour, so when an early large task ate the
541 - // whole cycle the bottom tasks were skipped run after run. Sorting by the
542 - // 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.
543 601 $cron_task_watermarks = array();
544 602 foreach ($cron_task_ids as $prop_id) {
545 - $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 + );
546 607 }
547 608 unset($cron_task_ids);
548 609
549 - // 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();
550 614 foreach (mlsimport_cron_task_order($cron_task_watermarks) as $prop_id) {
551 615 $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
552 616 mlsimport_debuglogs_per_plugin($logs);
553 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 +
554 642 // Call processing function for this item. The feed count it pulls
555 643 // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
556 644 $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
557 645
@@ -567,13 +655,16 @@
567 655 }
568 656 }
569 657
570 658 // Heartbeat (#208): clean finish — also resolves an open died-run incident.
571 - 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 + }
572 666
573 - // Release the run lock so the next scheduled run can proceed.
574 - delete_transient( 'mlsimport_cron_running' );
575 -
576 667 // last_sync_success is no longer stamped here (issue #207 finding 1): the
577 668 // end-of-loop stamp reported success even when every request failed, and
578 669 // never fired when a run died mid-loop. Each listings request now records
579 670 // its own outcome inside mlsimport_make_listing_requests().
@@ -910,12 +1001,12 @@
910 1001 // next to mlsimport_resolve_theme_id() whose answer it renders (#242).
911 1002
912 1003
913 1004
914 -// mlsimport_save_account_callback() (wp_ajax_mlsimport_save_account) moved to
915 -// includes/mlsimport-onboarding.php next to its sibling handler
916 -// mlsimport_ajax_test_account_connection(), so both credential-save paths live
917 -// 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.
918 1009
919 1010
920 1011
921 1012
@@ -923,8 +1014,11 @@
923 1014
924 1015 add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
925 1016 function mlsimport_save_mls_data_callback() {
926 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 + }
927 1021
928 1022 $options = get_option('mlsimport_admin_options', []);
929 1023 $options = is_array( $options ) ? $options : array();
930 1024 $previous_mls_id = isset( $options['mlsimport_mls_name'] )
@@ -946,10 +1040,14 @@
946 1040 Mlsimport_Provider_Family::clear_active_state();
947 1041 } else {
948 1042 Mlsimport_Provider_Family::clear_access_tokens();
949 1043 delete_option( 'mlsimport_connection_test' );
950 - delete_option( 'mlsimport_mls_metadata_populated' );
951 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 );
952 1050
953 1051 update_option('mlsimport_admin_options', $options);
954 1052
955 1053 // Always test the newly saved selection. Reusing a prior "yes" flag could