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 +273 -144 7.0.47.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.0.4
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.0.4');
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' );
@@ -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
@@ -188,18 +190,94 @@
188 190 * The core plugin class that is used to define internationalization,
189 191 * admin-specific hooks, and public-facing site hooks.
190 192 */
191 193
192 -// Composer autoloader (third-party libraries).
193 -require 'vendor/autoload.php';
194 +/*
195 + * Action Scheduler — the plugin's only third-party runtime dependency.
196 + *
197 + * This is deliberately a direct require rather than `vendor/autoload.php`.
198 + * Composer's generated autoloader is written differently depending on whether
199 + * dev dependencies (phpunit, php_codesniffer, myclabs/deep-copy) happen to be
200 + * installed at the time it was generated. Those dev packages are never shipped,
201 + * so a dev-generated autoloader committed to the repo makes `autoload_files.php`
202 + * eagerly require files that do not exist in the released plugin — a fatal error
203 + * on activation, before any plugin code runs.
204 + *
205 + * Action Scheduler is built to be dropped into a plugin and included directly
206 + * (that is how WooCommerce loads it); it registers its own class loader and
207 + * negotiates versions with any other copy already loaded on the site. So there
208 + * is nothing left for the Composer autoloader to do at runtime, and not shipping
209 + * it removes that whole failure mode. Composer is still used for dev tooling.
210 + */
211 +require_once plugin_dir_path( __FILE__ ) . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
194 212 // Core includes: RESO field defs + helpers, provider map, cron/reconciliation
195 213 // guards, status taxonomy/normalizer, then the orchestrator and API client.
196 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';
197 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';
198 220 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-reconciliation-guard.php';
199 221 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-cron-guard.php';
222 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-health.php';
200 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';
201 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';
202 280 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
203 281 require_once plugin_dir_path( __FILE__ ) . 'includes/ThemeImport.php';
204 282 require_once plugin_dir_path( __FILE__ ) . 'enviroment/StandaloneClass.php';
205 283 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResidenceClass.php';
@@ -210,16 +288,32 @@
210 288 require_once plugin_dir_path( __FILE__ ) . 'enviroment/SparkResoClass.php';
211 289 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BridgeResoClass.php';
212 290 require_once plugin_dir_path( __FILE__ ) . 'enviroment/TresleResoClass.php';
213 291 require_once plugin_dir_path( __FILE__ ) . 'enviroment/MlsgridResoClass.php';
214 -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';
215 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';
216 300 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
217 301
302 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
218 303 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
219 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';
220 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';
221 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';
222 316
223 317 /*
224 318 * Standalone (theme_id 990) mode — own listings table, CPTs and taxonomies.
225 319 * Registered on every load (ADR-0003); the table is created on activation and
@@ -254,8 +348,10 @@
254 348 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-elementor.php';
255 349 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-customizer.php'; // Standalone design settings in the WP Customizer (990 mode).
256 350 require_once plugin_dir_path( __FILE__ ) . 'includes/live/live-bootstrap.php'; // Live MLS passthrough mode (seam #1 — its only existing-file require).
257 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 );
258 354 add_action( 'init', array( 'Mlsimport_Property_Metabox', 'register' ) );
259 355 add_action( 'init', array( 'Mlsimport_Agent_Metabox', 'register' ) );
260 356 add_action( 'init', array( 'Mlsimport_Term_Meta', 'register' ) );
261 357 add_action( 'init', array( 'Mlsimport_Property_Columns', 'register' ) );
@@ -328,8 +424,10 @@
328 424 10,
329 425 3
330 426 );
331 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' ) );
332 430 add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
333 431 // Trash/unpublish (not a permanent delete) must also drop the listings row, so the
334 432 // search index only ever holds published listings.
335 433 add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
@@ -360,9 +458,11 @@
360 458 * Optimizations:
361 459 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
362 460 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
363 461 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
364 - * - 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
365 465 *
366 466 * @return void
367 467 */
368 468 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
@@ -374,8 +474,35 @@
374 474 global $mlsimport;
375 475
376 476 //error_log('[AutoCron] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
377 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 +
378 505 // 0. Bail if a run is already in progress. Without this guard an overlapping
379 506 // cron fire processes the same listings in parallel, causing duplicate
380 507 // listing_key inserts and term_relationship/term_count deadlocks. The TTL is
381 508 // the safety net if a run dies mid-loop without reaching the release below.
@@ -386,33 +513,50 @@
386 513 // 1. Get the API token from transient - exit if not set
387 514 $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
388 515 //error_log('[AutoCron] After token fetch: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
389 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' );
390 521 //error_log('[AutoCron] No token, exiting.');
391 522 return;
392 523 }
393 524
394 - // 2. Check if MLS connection is valid - exit if not
395 - $is_mls_connected = get_option('mlsimport_connection_test', '');
396 - //error_log('[AutoCron] After connection check: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
397 - if ('yes' !== $is_mls_connected) {
398 - //error_log('[AutoCron] No valid connection, exiting.');
399 - return;
400 - }
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.
401 529
402 530 // Claim the run lock now that we are committed to processing.
403 531 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
404 532
405 - // Record sync attempt in telemetry
406 - 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.
407 548 mlsimport_telemetry_set( 'last_sync_attempt', time() );
408 549
409 - // 3. Set batch size for processing and initialize loop variables
550 + // 3. Set batch size for gathering and initialize loop variables
410 551 $batch_size = 100;
411 552 $paged = 1;
412 553 $total_processed = 0;
413 554
414 - // 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();
415 559 do {
416 560 // Prepare query: only IDs, filter by meta key, batch, paged, no_found_rows speeds up query
417 561 $args = array(
418 562 'post_type' => 'mlsimport_item',
@@ -431,46 +575,101 @@
431 575 );
432 576
433 577 // Get post IDs for this batch
434 578 $post_ids = get_posts($args);
435 - //error_log("[AutoCron] Batch {$paged} fetched " . count($post_ids) . " items, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
436 579
437 - // If nothing is returned, break the loop
580 + // If nothing is returned, the gather is complete
438 581 if (empty($post_ids)) {
439 582 break;
440 583 }
441 584
442 - // 5. Loop through each post ID in this batch
443 585 foreach ($post_ids as $prop_id) {
444 - $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
445 - mlsimport_debuglogs_per_plugin($logs);
586 + $cron_task_ids[] = (int) $prop_id;
587 + }
446 588
447 - // Call processing function for this item. The feed count it pulls
448 - // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
449 - $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
589 + // Prepare next batch
590 + $paged++;
591 + unset($post_ids); // Free memory
450 592
451 - $total_processed++;
593 + } while (true);
452 594
453 - // Free memory every 100 processed items
454 - if ($total_processed % 100 === 0) {
455 - gc_collect_cycles();
456 - //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'] );
457 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;
458 640 }
459 641
460 - // 6. Prepare next batch
461 - $paged++;
462 - unset($post_ids); // Free memory
463 - gc_collect_cycles(); // Trigger garbage collection
464 - //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);
465 645
466 - } while (true);
646 + $total_processed++;
467 647
468 - // Release the run lock so the next scheduled run can proceed.
469 - delete_transient( 'mlsimport_cron_running' );
648 + // Heartbeat (#208): measurable progress for the stuck-run check.
649 + mlsimport_cron_heartbeat_progress( $total_processed );
470 650
471 - 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 + }
472 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 +
473 672 //error_log('[AutoCron] Done, total processed: ' . $total_processed . ', end memory: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
474 673 }
475 674
476 675
@@ -486,8 +685,9 @@
486 685 wp_schedule_event( time(), 'daily', 'mlsimport_reconciliation_event' );
487 686 }
488 687
489 688 add_action( 'mlsimport_reconciliation_event', 'mlsimport_saas_reconciliation_event_function' );
689 +add_action( 'mlsimport_reconciliation_retry_event', 'mlsimport_saas_reconciliation_event_function' );
490 690
491 691 if ( ! wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) {
492 692 wp_schedule_event( time(), 'daily', 'mlsimport_daily_telemetry_event' );
493 693 }
@@ -796,118 +996,35 @@
796 996 echo "Term counts have been recalculated for all taxonomies.";
797 997 }
798 998
799 999
800 -/*
801 - *
802 - * create dropdown list
803 - *
804 - *
805 - */
806 -/**
807 - * Build a <select> for a mlsimport_admin_options[$key] field.
808 - *
809 - * @param string $key Option key (used as id and name suffix).
810 - * @param mixed $value Currently selected option value.
811 - * @param array $data_array Map of option value => label.
812 - * @return string The rendered <select> HTML.
813 - */
814 -function mlsiport_mls_select_list( $key, $value, $data_array ) {
815 - // Open the select, binding it to the mlsimport_admin_options[$key] field.
816 - $select = '<select class="mlsimport-2025-select" id="' . esc_attr( $key ) . '" name="mlsimport_admin_options[' . $key . ']">';
817 - // Only build options when given an array of choices.
818 - if ( is_array( $data_array ) ) :
819 - // Emit one <option> per choice.
820 - foreach ( $data_array as $key => $mls_item ) {
821 - $select .= '<option value="' .esc_attr( $key ). '"';
822 - // Mark the option matching the current value as selected.
823 - if ( intval( $value ) === intval( $key ) ) {
824 - $select .= ' selected ';
825 - }
826 - $select .= '>' .esc_html( $mls_item ). '</option>';
827 - }
828 - endif;
829 - // Close the select and return the assembled markup.
830 - $select .= '</select>';
831 - return $select;
832 -}
1000 +// The theme <select> builder lives in includes/mlsimport-theme-detection.php,
1001 +// next to mlsimport_resolve_theme_id() whose answer it renders (#242).
833 1002
834 1003
835 1004
836 -add_action('wp_ajax_mlsimport_save_account', 'mlsimport_save_account_callback');
837 -/**
838 - * AJAX handler: save the MLSImport account username/password and test the login.
839 - *
840 - * Verifies the onboarding nonce, stores credentials in mlsimport_admin_options,
841 - * fetches a fresh API token, and returns connected/not-connected HTML + flag.
842 - *
843 - * @return void
844 - */
845 -function mlsimport_save_account_callback() {
846 - // Verify the shared onboarding AJAX nonce.
847 - 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.
848 1009
849 - // Load current plugin options.
850 - $options = get_option('mlsimport_admin_options', []);
851 - // Persist the submitted credentials only when both are present.
852 - if ( ! empty($_POST['mlsimport_username']) && ! empty($_POST['mlsimport_password']) ) {
853 - $options['mlsimport_username'] = sanitize_text_field($_POST['mlsimport_username']);
854 - $options['mlsimport_password'] = sanitize_text_field($_POST['mlsimport_password']);
855 - update_option('mlsimport_admin_options', $options);
856 - }
857 1010
858 - global $mlsimport;
859 1011
860 - // Refresh token
861 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
862 1012
863 - // Empty token means the credentials did not authenticate.
864 - if (trim($token) === '') {
865 - // Buffer the "not connected" warning markup.
866 - ob_start();
867 -
868 - ?>
869 - <div class="mlsimport_warning">
870 - <?php esc_html_e('You are not connected to MlsImport - Please check your Username and Password.', 'mlsimport'); ?>
871 - </div>
872 - <?php
873 - $html = ob_get_clean();
874 1013
875 - // Return failure HTML + connected=false.
876 - wp_send_json_success([
877 - 'message' => __('You are not connected.', 'mlsimport'),
878 - 'html' => $html,
879 - 'connected' => false
880 - ]);
881 - } else {
882 - ob_start();
883 - ?>
884 - <div class="mlsimport_warning mlsimport_validated">
885 - <?php esc_html_e('You are connected to your MlsImport account!', 'mlsimport'); ?>
886 - </div>
887 - <?php
888 - $html = ob_get_clean();
889 1014
890 - wp_send_json_success([
891 - 'message' => __('Connected successfully!', 'mlsimport'),
892 - 'html' => $html,
893 - 'connected' => true
894 - ]);
895 - }
896 -}
897 -
898 -
899 -
900 -
901 -
902 -
903 -
904 -
905 1015 add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
906 1016 function mlsimport_save_mls_data_callback() {
907 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 + }
908 1021
909 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 + : '';
910 1027
911 1028 foreach ($_POST as $key => $value) {
912 1029 if (strpos($key, 'mlsimport_') === 0 && $key !== 'mlsimport_username' && $key !== 'mlsimport_password') {
913 1030 $options[$key] = sanitize_text_field($value);
@@ -913,20 +1030,34 @@
913 1030 $options[$key] = sanitize_text_field($value);
914 1031 }
915 1032 }
916 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 +
917 1051 update_option('mlsimport_admin_options', $options);
918 1052
919 - // 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.
920 1055 global $mlsimport;
1056 + $mlsimport->admin->mlsimport_saas_setting_up();
1057 + $mlsimport->admin->mlsimport_saas_check_mls_connection();
921 1058 $is_mls_connected = get_option('mlsimport_connection_test', '');
922 - $mlsimport->admin->mlsimport_saas_setting_up();
923 1059
924 - if ('yes' !== $is_mls_connected) {
925 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
926 - $is_mls_connected = get_option('mlsimport_connection_test', '');
927 - }
928 -
929 1060 ob_start();
930 1061 if ('yes' === $is_mls_connected) {
931 1062 ?>
932 1063 <div class="mlsimport_warning mlsimport_validated">
@@ -947,6 +1078,4 @@
947 1078 'html' => $html,
948 1079 'connected' => $is_mls_connected === 'yes',
949 1080 ]);
950 1081 }
951 -
952 -