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 | mlsimport.php +255 -142 7.0.67.2 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.0.6
6 + * Version: 7.2
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.0.6');
43 +define( 'MLSIMPORT_VERSION', '7.2');
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' );
@@ -95,9 +95,11 @@
95 95 function mlsimport_deactivate() {
96 96 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-deactivator.php';
97 97 wp_clear_scheduled_hook( 'event_mls_import_auto' );
98 98 wp_clear_scheduled_hook( 'mlsimport_reconciliation_event' );
99 + wp_clear_scheduled_hook( 'mlsimport_reconciliation_retry_event' );
99 100 wp_clear_scheduled_hook( 'mlsimport_daily_telemetry_event' );
101 + delete_option( 'mlsimport_reconciliation_running' );
100 102 Mlsimport_Deactivator::deactivate();
101 103 }
102 104
103 105
@@ -209,13 +211,73 @@
209 211 require_once plugin_dir_path( __FILE__ ) . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
210 212 // Core includes: RESO field defs + helpers, provider map, cron/reconciliation
211 213 // guards, status taxonomy/normalizer, then the orchestrator and API client.
212 214 require_once plugin_dir_path( __FILE__ ) . 'includes/help_functions.php';
215 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-theme-detection.php';
216 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-credentials.php';
213 217 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-provider-map.php';
218 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-enum-labels.php';
219 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-country.php';
214 220 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-reconciliation-guard.php';
215 221 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-cron-guard.php';
222 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-health.php';
216 223 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-status-taxonomy.php';
224 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-reconciliation.php';
225 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-reconciliation-wordpress-environment.php';
226 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-import-task-execution.php';
227 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-import-task-execution-wordpress-environment.php';
217 228 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-status-normalize.php';
229 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-fields.php';
230 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-title.php';
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';
277 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-wordpress-environment.php';
278 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-write.php';
279 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-adapter-factory.php';
218 280 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
219 281 require_once plugin_dir_path( __FILE__ ) . 'includes/ThemeImport.php';
220 282 require_once plugin_dir_path( __FILE__ ) . 'enviroment/StandaloneClass.php';
221 283 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResidenceClass.php';
@@ -226,16 +288,32 @@
226 288 require_once plugin_dir_path( __FILE__ ) . 'enviroment/SparkResoClass.php';
227 289 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BridgeResoClass.php';
228 290 require_once plugin_dir_path( __FILE__ ) . 'enviroment/TresleResoClass.php';
229 291 require_once plugin_dir_path( __FILE__ ) . 'enviroment/MlsgridResoClass.php';
230 -require_once plugin_dir_path( __FILE__ ) . 'enviroment/MlsgridResoClass.php';
292 +require_once plugin_dir_path( __FILE__ ) . 'enviroment/BrightMlsResoClass.php';
293 +require_once plugin_dir_path( __FILE__ ) . 'enviroment/CentrisResoClass.php';
294 +require_once plugin_dir_path( __FILE__ ) . 'enviroment/ProviderResoClasses.php';
295 +require_once plugin_dir_path( __FILE__ ) . 'enviroment/UnsupportedResoClass.php';
231 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';
232 300 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
233 301
302 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
234 303 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
235 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';
308 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-autotrigger.php';
236 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';
237 312 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-activity-log.php';
313 +// #208: internal incident alerts (dedup + resolve) and import/connection health watch.
314 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-alerts.php';
315 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-import-health.php';
238 316
239 317 /*
240 318 * Standalone (theme_id 990) mode — own listings table, CPTs and taxonomies.
241 319 * Registered on every load (ADR-0003); the table is created on activation and
@@ -270,8 +348,10 @@
270 348 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-elementor.php';
271 349 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-customizer.php'; // Standalone design settings in the WP Customizer (990 mode).
272 350 require_once plugin_dir_path( __FILE__ ) . 'includes/live/live-bootstrap.php'; // Live MLS passthrough mode (seam #1 — its only existing-file require).
273 351 add_action( 'init', array( 'Mlsimport_Standalone_Cpt', 'register' ) );
352 +// After register(): drop cached rewrite rules when the standalone-mode signature changed (#206).
353 +add_action( 'init', array( 'Mlsimport_Standalone_Cpt', 'maybe_flush_rewrites' ), 20 );
274 354 add_action( 'init', array( 'Mlsimport_Property_Metabox', 'register' ) );
275 355 add_action( 'init', array( 'Mlsimport_Agent_Metabox', 'register' ) );
276 356 add_action( 'init', array( 'Mlsimport_Term_Meta', 'register' ) );
277 357 add_action( 'init', array( 'Mlsimport_Property_Columns', 'register' ) );
@@ -344,8 +424,10 @@
344 424 10,
345 425 3
346 426 );
347 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' ) );
348 430 add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
349 431 // Trash/unpublish (not a permanent delete) must also drop the listings row, so the
350 432 // search index only ever holds published listings.
351 433 add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
@@ -376,9 +458,11 @@
376 458 * Optimizations:
377 459 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
378 460 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
379 461 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
380 - * - 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
381 465 *
382 466 * @return void
383 467 */
384 468 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
@@ -390,8 +474,35 @@
390 474 global $mlsimport;
391 475
392 476 //error_log('[AutoCron] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
393 477
478 + // Watchdog backstop (issue #199): a chunked manual import whose worker
479 + // chain died is normally revived by the polled progress screen, but if
480 + // the administrator closed that screen nothing else watches the run.
481 + // Hourly cron picks it up here; revive() is a cheap no-op for anything
482 + // that is not a silent, stalled manual run.
483 + // Import-health checks (#208) run before anything can bail out below:
484 + // detect a previous cron run that died mid-loop, and a manual run stuck
485 + // at "Preparing". Both open one deduplicated internal incident.
486 + mlsimport_cron_heartbeat_check();
487 + mlsimport_import_health_watch_manual_run();
488 +
489 + $mlsimport_active_lock = get_option( 'mlsimport_import_run_lock', array() );
490 + if ( is_array( $mlsimport_active_lock ) && ! empty( $mlsimport_active_lock['task_id'] ) ) {
491 + $mlsimport_revive = $mlsimport->admin->mlsimport_import_task_execution()->revive( (int) $mlsimport_active_lock['task_id'] );
492 + if ( true === ( $mlsimport_revive['revived'] ?? false ) ) {
493 + mlsimport_saas_single_write_import_custom_logs( 'Hourly watchdog revived the import worker chain for task ' . (int) $mlsimport_active_lock['task_id'] . '.' . PHP_EOL, 'manual' );
494 + } elseif ( 'stalled' === ( $mlsimport_revive['reason'] ?? '' ) ) {
495 + mlsimport_saas_single_write_import_custom_logs( 'Hourly watchdog declared the import run for task ' . (int) $mlsimport_active_lock['task_id'] . ' stalled and failed it.' . PHP_EOL, 'manual' );
496 + // The run was terminated as hopeless — tell the SaaS once (#208).
497 + mlsimport_alert_open(
498 + 'import_stalled:' . (int) $mlsimport_active_lock['task_id'],
499 + 'import_stalled',
500 + array( 'task_id' => (int) $mlsimport_active_lock['task_id'] )
501 + );
502 + }
503 + }
504 +
394 505 // 0. Bail if a run is already in progress. Without this guard an overlapping
395 506 // cron fire processes the same listings in parallel, causing duplicate
396 507 // listing_key inserts and term_relationship/term_count deadlocks. The TTL is
397 508 // the safety net if a run dies mid-loop without reaching the release below.
@@ -402,33 +513,50 @@
402 513 // 1. Get the API token from transient - exit if not set
403 514 $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
404 515 //error_log('[AutoCron] After token fetch: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
405 516 if (trim($token) === '') {
517 + // Silent exit made frozen sites undiagnosable (issue #207 finding 3):
518 + // record the failed attempt with a real class before bailing.
519 + mlsimport_telemetry_set( 'last_sync_failed', time() );
520 + mlsimport_telemetry_set( 'last_sync_failed_code', 'no_token' );
406 521 //error_log('[AutoCron] No token, exiting.');
407 522 return;
408 523 }
409 524
410 - // 2. Check if MLS connection is valid - exit if not
411 - $is_mls_connected = get_option('mlsimport_connection_test', '');
412 - //error_log('[AutoCron] After connection check: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
413 - if ('yes' !== $is_mls_connected) {
414 - //error_log('[AutoCron] No valid connection, exiting.');
415 - return;
416 - }
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.
417 529
418 530 // Claim the run lock now that we are committed to processing.
419 531 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
420 532
421 - // Record sync attempt in telemetry
422 - mlsimport_telemetry_bump( 'syncs' );
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 {
538 + // Heartbeat (#208): record that a cron import is now running, so the next
539 + // cron entry can tell a clean finish from a process that died mid-loop.
540 + mlsimport_cron_heartbeat_start();
541 +
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.
423 548 mlsimport_telemetry_set( 'last_sync_attempt', time() );
424 549
425 - // 3. Set batch size for processing and initialize loop variables
550 + // 3. Set batch size for gathering and initialize loop variables
426 551 $batch_size = 100;
427 552 $paged = 1;
428 553 $total_processed = 0;
429 554
430 - // 4. Process in batches until no more items are found
555 + // 4. Gather every cron-enabled task id first (ids only — a few bytes each,
556 + // still fetched in paged batches so the query never loads post objects).
557 + // Gathering before processing is what makes fair ordering possible below.
558 + $cron_task_ids = array();
431 559 do {
432 560 // Prepare query: only IDs, filter by meta key, batch, paged, no_found_rows speeds up query
433 561 $args = array(
434 562 'post_type' => 'mlsimport_item',
@@ -447,46 +575,101 @@
447 575 );
448 576
449 577 // Get post IDs for this batch
450 578 $post_ids = get_posts($args);
451 - //error_log("[AutoCron] Batch {$paged} fetched " . count($post_ids) . " items, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
452 579
453 - // If nothing is returned, break the loop
580 + // If nothing is returned, the gather is complete
454 581 if (empty($post_ids)) {
455 582 break;
456 583 }
457 584
458 - // 5. Loop through each post ID in this batch
459 585 foreach ($post_ids as $prop_id) {
460 - $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
461 - mlsimport_debuglogs_per_plugin($logs);
586 + $cron_task_ids[] = (int) $prop_id;
587 + }
462 588
463 - // Call processing function for this item. The feed count it pulls
464 - // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
465 - $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
589 + // Prepare next batch
590 + $paged++;
591 + unset($post_ids); // Free memory
466 592
467 - $total_processed++;
593 + } while (true);
468 594
469 - // Free memory every 100 processed items
470 - if ($total_processed % 100 === 0) {
471 - gc_collect_cycles();
472 - //error_log("[AutoCron] Processed {$total_processed} total, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
595 + // 5. Order by starvation (issue #203): the query above returns tasks in
596 + // the same fixed order every hour, so when an early large task ate the
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.
601 + $cron_task_watermarks = array();
602 + foreach ($cron_task_ids as $prop_id) {
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 + );
607 + }
608 + unset($cron_task_ids);
609 +
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();
614 + foreach (mlsimport_cron_task_order($cron_task_watermarks) as $prop_id) {
615 + $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
616 + mlsimport_debuglogs_per_plugin($logs);
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'] );
473 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;
474 640 }
475 641
476 - // 6. Prepare next batch
477 - $paged++;
478 - unset($post_ids); // Free memory
479 - gc_collect_cycles(); // Trigger garbage collection
480 - //error_log("[AutoCron] After batch {$paged}, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
642 + // Call processing function for this item. The feed count it pulls
643 + // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
644 + $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
481 645
482 - } while (true);
646 + $total_processed++;
483 647
484 - // Release the run lock so the next scheduled run can proceed.
485 - delete_transient( 'mlsimport_cron_running' );
648 + // Heartbeat (#208): measurable progress for the stuck-run check.
649 + mlsimport_cron_heartbeat_progress( $total_processed );
486 650
487 - mlsimport_telemetry_set( 'last_sync_success', time() );
651 + // Free memory every 100 processed items
652 + if ($total_processed % 100 === 0) {
653 + gc_collect_cycles();
654 + //error_log("[AutoCron] Processed {$total_processed} total, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
655 + }
656 + }
488 657
658 + // Heartbeat (#208): clean finish — also resolves an open died-run incident.
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 + }
666 +
667 + // last_sync_success is no longer stamped here (issue #207 finding 1): the
668 + // end-of-loop stamp reported success even when every request failed, and
669 + // never fired when a run died mid-loop. Each listings request now records
670 + // its own outcome inside mlsimport_make_listing_requests().
671 +
489 672 //error_log('[AutoCron] Done, total processed: ' . $total_processed . ', end memory: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
490 673 }
491 674
492 675
@@ -502,8 +685,9 @@
502 685 wp_schedule_event( time(), 'daily', 'mlsimport_reconciliation_event' );
503 686 }
504 687
505 688 add_action( 'mlsimport_reconciliation_event', 'mlsimport_saas_reconciliation_event_function' );
689 +add_action( 'mlsimport_reconciliation_retry_event', 'mlsimport_saas_reconciliation_event_function' );
506 690
507 691 if ( ! wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) {
508 692 wp_schedule_event( time(), 'daily', 'mlsimport_daily_telemetry_event' );
509 693 }
@@ -812,118 +996,35 @@
812 996 echo "Term counts have been recalculated for all taxonomies.";
813 997 }
814 998
815 999
816 -/*
817 - *
818 - * create dropdown list
819 - *
820 - *
821 - */
822 -/**
823 - * Build a <select> for a mlsimport_admin_options[$key] field.
824 - *
825 - * @param string $key Option key (used as id and name suffix).
826 - * @param mixed $value Currently selected option value.
827 - * @param array $data_array Map of option value => label.
828 - * @return string The rendered <select> HTML.
829 - */
830 -function mlsiport_mls_select_list( $key, $value, $data_array ) {
831 - // Open the select, binding it to the mlsimport_admin_options[$key] field.
832 - $select = '<select class="mlsimport-2025-select" id="' . esc_attr( $key ) . '" name="mlsimport_admin_options[' . $key . ']">';
833 - // Only build options when given an array of choices.
834 - if ( is_array( $data_array ) ) :
835 - // Emit one <option> per choice.
836 - foreach ( $data_array as $key => $mls_item ) {
837 - $select .= '<option value="' .esc_attr( $key ). '"';
838 - // Mark the option matching the current value as selected.
839 - if ( intval( $value ) === intval( $key ) ) {
840 - $select .= ' selected ';
841 - }
842 - $select .= '>' .esc_html( $mls_item ). '</option>';
843 - }
844 - endif;
845 - // Close the select and return the assembled markup.
846 - $select .= '</select>';
847 - return $select;
848 -}
1000 +// The theme <select> builder lives in includes/mlsimport-theme-detection.php,
1001 +// next to mlsimport_resolve_theme_id() whose answer it renders (#242).
849 1002
850 1003
851 1004
852 -add_action('wp_ajax_mlsimport_save_account', 'mlsimport_save_account_callback');
853 -/**
854 - * AJAX handler: save the MLSImport account username/password and test the login.
855 - *
856 - * Verifies the onboarding nonce, stores credentials in mlsimport_admin_options,
857 - * fetches a fresh API token, and returns connected/not-connected HTML + flag.
858 - *
859 - * @return void
860 - */
861 -function mlsimport_save_account_callback() {
862 - // Verify the shared onboarding AJAX nonce.
863 - check_ajax_referer('mlsimport_onboarding_nonce', 'security');
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.
864 1009
865 - // Load current plugin options.
866 - $options = get_option('mlsimport_admin_options', []);
867 - // Persist the submitted credentials only when both are present.
868 - if ( ! empty($_POST['mlsimport_username']) && ! empty($_POST['mlsimport_password']) ) {
869 - $options['mlsimport_username'] = sanitize_text_field($_POST['mlsimport_username']);
870 - $options['mlsimport_password'] = sanitize_text_field($_POST['mlsimport_password']);
871 - update_option('mlsimport_admin_options', $options);
872 - }
873 1010
874 - global $mlsimport;
875 1011
876 - // Refresh token
877 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
878 1012
879 - // Empty token means the credentials did not authenticate.
880 - if (trim($token) === '') {
881 - // Buffer the "not connected" warning markup.
882 - ob_start();
883 -
884 - ?>
885 - <div class="mlsimport_warning">
886 - <?php esc_html_e('You are not connected to MlsImport - Please check your Username and Password.', 'mlsimport'); ?>
887 - </div>
888 - <?php
889 - $html = ob_get_clean();
890 1013
891 - // Return failure HTML + connected=false.
892 - wp_send_json_success([
893 - 'message' => __('You are not connected.', 'mlsimport'),
894 - 'html' => $html,
895 - 'connected' => false
896 - ]);
897 - } else {
898 - ob_start();
899 - ?>
900 - <div class="mlsimport_warning mlsimport_validated">
901 - <?php esc_html_e('You are connected to your MlsImport account!', 'mlsimport'); ?>
902 - </div>
903 - <?php
904 - $html = ob_get_clean();
905 1014
906 - wp_send_json_success([
907 - 'message' => __('Connected successfully!', 'mlsimport'),
908 - 'html' => $html,
909 - 'connected' => true
910 - ]);
911 - }
912 -}
913 -
914 -
915 -
916 -
917 -
918 -
919 -
920 -
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', []);
1023 + $options = is_array( $options ) ? $options : array();
1024 + $previous_mls_id = isset( $options['mlsimport_mls_name'] )
1025 + ? (string) $options['mlsimport_mls_name']
1026 + : '';
926 1027
927 1028 foreach ($_POST as $key => $value) {
928 1029 if (strpos($key, 'mlsimport_') === 0 && $key !== 'mlsimport_username' && $key !== 'mlsimport_password') {
929 1030 $options[$key] = sanitize_text_field($value);
@@ -929,20 +1030,34 @@
929 1030 $options[$key] = sanitize_text_field($value);
930 1031 }
931 1032 }
932 1033
1034 + // An MLS change invalidates only state owned by the old selection. Provider
1035 + // credentials remain saved so returning to that provider restores its fields.
1036 + $new_mls_id = isset( $options['mlsimport_mls_name'] )
1037 + ? (string) $options['mlsimport_mls_name']
1038 + : '';
1039 + if ( $previous_mls_id !== $new_mls_id ) {
1040 + Mlsimport_Provider_Family::clear_active_state();
1041 + } else {
1042 + Mlsimport_Provider_Family::clear_access_tokens();
1043 + delete_option( 'mlsimport_connection_test' );
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 );
1050 +
933 1051 update_option('mlsimport_admin_options', $options);
934 1052
935 - // Run MLS connection check
1053 + // Always test the newly saved selection. Reusing a prior "yes" flag could
1054 + // incorrectly report that a different MLS or changed credentials succeeded.
936 1055 global $mlsimport;
1056 + $mlsimport->admin->mlsimport_saas_setting_up();
1057 + $mlsimport->admin->mlsimport_saas_check_mls_connection();
937 1058 $is_mls_connected = get_option('mlsimport_connection_test', '');
938 - $mlsimport->admin->mlsimport_saas_setting_up();
939 1059
940 - if ('yes' !== $is_mls_connected) {
941 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
942 - $is_mls_connected = get_option('mlsimport_connection_test', '');
943 - }
944 -
945 1060 ob_start();
946 1061 if ('yes' === $is_mls_connected) {
947 1062 ?>
948 1063 <div class="mlsimport_warning mlsimport_validated">
@@ -963,6 +1078,4 @@
963 1078 'html' => $html,
964 1079 'connected' => $is_mls_connected === 'yes',
965 1080 ]);
966 1081 }
967 -
968 -