PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1
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 6.1.0 All 34 releases
mlsimport / mlsimport.php

mlsimport.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1, at mlsimport.php

981 lines 43.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: MlsImport
4 * Plugin URI: https://mlsimport.com/
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
7 * Requires at least: 5.2
8 * Requires PHP: 7.4
9 * License: GPLv3
10 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
11 * Author: MlsImport
12 * Text Domain: mlsimport
13 * Domain Path: /languages
14 */
15
16 /*
17 * ---------------------------------------------------------------------------
18 * FILE ROLE: main plugin bootstrap.
19 * ---------------------------------------------------------------------------
20 * Responsibilities of this file, in load order:
21 * 1. Define global constants (version, API endpoint, paths, cron batch size).
22 * 2. Register activation/deactivation hooks and one-time upgrade notices.
23 * 3. Track the installed version and flag upgrade modals.
24 * 4. require_once every plugin PHP file (core, API client, theme/provider
25 * adapters, onboarding, telemetry, and the whole standalone/ module).
26 * 5. Wire the standalone (theme_id 990) init/enqueue/template hooks.
27 * 6. Schedule the WP-Cron events (hourly import, daily reconciliation, daily
28 * telemetry) and define their handler functions.
29 * 7. Instantiate the core Mlsimport class and call run() to register hooks.
30 * 8. Define assorted global helper functions (logging, dropdowns, onboarding
31 * AJAX save handlers).
32 * ---------------------------------------------------------------------------
33 */
34
35 // If this file is called directly, abort.
36
37 if ( ! defined( 'WPINC' ) ) {
38 die;
39 }
40
41
42 // Current plugin version (kept in sync with the header above and the readme).
43 define( 'MLSIMPORT_VERSION', '7.1');
44 // Marketing/portal host used to build sign-up and affiliate links.
45 define( 'MLSIMPORT_CLUBLINK', 'mlsimport.com' );
46 // Scheme for the portal host links.
47 define( 'MLSIMPORT_CLUBLINKSSL', 'https' );
48 // Default per-request import batch size (listings pulled per cron step).
49 define( 'MLSIMPORT_CRON_STEP', 20 );
50 // Absolute filesystem path to this plugin directory (trailing slash).
51 define( 'MLSIMPORT_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
52 // Public URL to this plugin directory (trailing slash).
53 define( 'MLSIMPORT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
54
55
56 // SaaS API base URL (AWS API Gateway). The two commented lines are the legacy
57 // vanity host and the old "dev" stage; the active endpoint is the "blue" stage.
58 //define( 'MLSIMPORT_API_URL', 'https://requests.mlsimport.com/' );
59 //define( 'MLSIMPORT_API_URL', 'https://pyjzsilw7b.execute-api.us-east-1.amazonaws.com/dev/' );
60 define( 'MLSIMPORT_API_URL', 'https://srky9ddikl.execute-api.us-east-1.amazonaws.com/blue/');
61
62
63
64
65
66
67 // Allow a site to pre-define this constant (e.g. in wp-config.php) to hide the
68 // "finish setup" admin notice; default to showing it when not already defined.
69 if ( ! defined( 'MLSIMPORT_HIDE_SETUP_NOTICE' ) ) {
70 define( 'MLSIMPORT_HIDE_SETUP_NOTICE', false );
71 }
72
73
74
75 /**
76 * The code that runs during plugin activation.
77 * This action is documented in includes/class-mlsimport-activator.php
78 */
79 function mlsimport_activate() {
80 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-activator.php';
81 Mlsimport_Activator::activate();
82 mlsimport_telemetry_set_once( 'installed_at', time() );
83
84 // Standalone (theme_id 990) search table.
85 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-table.php';
86 Mlsimport_Standalone_Table::create();
87 }
88
89
90
91 /**
92 * The code that runs during plugin deactivation.
93 * This action is documented in includes/class-mlsimport-deactivator.php
94 */
95 function mlsimport_deactivate() {
96 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-deactivator.php';
97 wp_clear_scheduled_hook( 'event_mls_import_auto' );
98 wp_clear_scheduled_hook( 'mlsimport_reconciliation_event' );
99 wp_clear_scheduled_hook( 'mlsimport_reconciliation_retry_event' );
100 wp_clear_scheduled_hook( 'mlsimport_daily_telemetry_event' );
101 delete_option( 'mlsimport_reconciliation_running' );
102 Mlsimport_Deactivator::deactivate();
103 }
104
105
106
107 register_activation_hook( __FILE__, 'mlsimport_activate' );
108 register_deactivation_hook( __FILE__, 'mlsimport_deactivate' );
109
110 /**
111 * Show one-time modal about the switch from Delete Statuses to Protected Statuses.
112 */
113 add_action( 'admin_footer', 'mlsimport_protected_statuses_upgrade_modal' );
114 function mlsimport_protected_statuses_upgrade_modal() {
115 // Bail if the user already acknowledged/dismissed the notice.
116 if ( get_option( 'mlsimport_dismiss_protected_status_notice' ) ) {
117 return;
118 }
119 // Only show for sites that had a version before 6.2 (not fresh installs)
120 $show_modal = get_option( 'mlsimport_show_protected_status_modal' );
121 // Bail when the upgrade flag was never set for this site.
122 if ( ! $show_modal ) {
123 return;
124 }
125 // Nonce for the dismissal AJAX call embedded in the modal's inline script.
126 $nonce = wp_create_nonce( 'mlsimport_dismiss_protected_notice' );
127 // Link the user to their Import Tasks list to set Protected Statuses.
128 $import_tasks_url = admin_url( 'edit.php?post_type=mlsimport_item' );
129 // Emit the modal markup + inline dismissal script into the admin footer.
130 ?>
131 <div id="mlsimport-protected-status-modal" style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:999999;display:flex;align-items:center;justify-content:center;">
132 <div style="background:#fff;max-width:520px;width:90%;border-radius:8px;padding:30px;box-shadow:0 4px 20px rgba(0,0,0,0.3);">
133 <h2 style="margin-top:0;color:#d63638;">MLSImport - Important Change</h2>
134 <p><strong>"Delete Statuses"</strong> have been removed. The plugin now uses <strong>Protected Statuses</strong> only.</p>
135 <p>Properties with a Protected Status will be kept during reconciliation. All other properties no longer found in MLS <strong>will be deleted</strong>.</p>
136 <p style="background:#fff3cd;border-left:4px solid #dba617;padding:10px 14px;"><strong>Action required:</strong> Go to each of your <a href="<?php echo esc_url( $import_tasks_url ); ?>">Import Tasks</a> and set the Protected Statuses field to the statuses you want to keep (e.g. Active, Pending, Coming Soon).</p>
137 <button id="mlsimport-acknowledge-btn" class="button button-primary" style="margin-top:10px;font-size:14px;padding:6px 24px;">I acknowledge</button>
138 </div>
139 </div>
140 <script>
141 document.getElementById('mlsimport-acknowledge-btn').addEventListener('click', function() {
142 var btn = this;
143 btn.disabled = true;
144 btn.textContent = 'Saving...';
145 var xhr = new XMLHttpRequest();
146 xhr.open('POST', ajaxurl);
147 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
148 xhr.onload = function() {
149 document.getElementById('mlsimport-protected-status-modal').style.display = 'none';
150 };
151 xhr.send('action=mlsimport_dismiss_protected_notice&_wpnonce=<?php echo esc_js( $nonce ); ?>');
152 });
153 </script>
154 <?php
155 }
156 add_action( 'wp_ajax_mlsimport_dismiss_protected_notice', 'mlsimport_handle_dismiss_protected_notice' );
157 /**
158 * AJAX handler: persist the user's dismissal of the protected-statuses modal.
159 *
160 * Verifies the nonce, records the permanent "dismissed" flag, clears the
161 * "show modal" flag, and returns a success JSON response.
162 *
163 * @return void
164 */
165 function mlsimport_handle_dismiss_protected_notice() {
166 // Verify the nonce created in the modal markup above.
167 check_ajax_referer( 'mlsimport_dismiss_protected_notice' );
168 // Persist that this notice has been acknowledged so it never shows again.
169 update_option( 'mlsimport_dismiss_protected_status_notice', true );
170 // Remove the one-time "show modal" flag.
171 delete_option( 'mlsimport_show_protected_status_modal' );
172 // Return an empty success payload to the inline script.
173 wp_send_json_success();
174 }
175
176 /**
177 * Track installed version and flag upgrade modals.
178 * Fresh installs get current version immediately, so no upgrade modal.
179 * Upgrades from < 6.2 flag the protected status modal.
180 */
181 $mlsimport_prev_version = get_option( 'mlsimport_installed_version', '' );
182 if ( $mlsimport_prev_version !== MLSIMPORT_VERSION ) {
183 if ( ! empty( $mlsimport_prev_version ) && version_compare( $mlsimport_prev_version, '6.2', '<' ) ) {
184 update_option( 'mlsimport_show_protected_status_modal', true );
185 }
186 update_option( 'mlsimport_installed_version', MLSIMPORT_VERSION );
187 }
188
189 /**
190 * The core plugin class that is used to define internationalization,
191 * admin-specific hooks, and public-facing site hooks.
192 */
193
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';
212 // Core includes: RESO field defs + helpers, provider map, cron/reconciliation
213 // guards, status taxonomy/normalizer, then the orchestrator and API client.
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';
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';
220 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-reconciliation-guard.php';
221 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-cron-guard.php';
222 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-health.php';
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';
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/class-mlsimport-stored-listing-wordpress-environment.php';
233 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-write.php';
234 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-adapter-factory.php';
235 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
236 require_once plugin_dir_path( __FILE__ ) . 'includes/ThemeImport.php';
237 require_once plugin_dir_path( __FILE__ ) . 'enviroment/StandaloneClass.php';
238 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResidenceClass.php';
239 require_once plugin_dir_path( __FILE__ ) . 'enviroment/EstateClass.php';
240 require_once plugin_dir_path( __FILE__ ) . 'enviroment/HouzezClass.php';
241 require_once plugin_dir_path( __FILE__ ) . 'enviroment/RealHomesClass.php';
242 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResoBase.php';
243 require_once plugin_dir_path( __FILE__ ) . 'enviroment/SparkResoClass.php';
244 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BridgeResoClass.php';
245 require_once plugin_dir_path( __FILE__ ) . 'enviroment/TresleResoClass.php';
246 require_once plugin_dir_path( __FILE__ ) . 'enviroment/MlsgridResoClass.php';
247 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BrightMlsResoClass.php';
248 require_once plugin_dir_path( __FILE__ ) . 'enviroment/CentrisResoClass.php';
249 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ProviderResoClasses.php';
250 require_once plugin_dir_path( __FILE__ ) . 'enviroment/UnsupportedResoClass.php';
251 require_once plugin_dir_path( __FILE__ ) . 'includes/addons/agents_offices.php';
252 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
253
254 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
255 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
256 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-progressive-save.php';
257 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-autotrigger.php';
258 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-telemetry.php';
259 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-activity-log.php';
260 // #208: internal incident alerts (dedup + resolve) and import/connection health watch.
261 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-alerts.php';
262 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-import-health.php';
263
264 /*
265 * Standalone (theme_id 990) mode — own listings table, CPTs and taxonomies.
266 * Registered on every load (ADR-0003); the table is created on activation and
267 * upgraded behind a version guard on admin_init.
268 */
269 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/mlsimport-hooks.php'; // Hook reference + convention (loaded early).
270 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-table.php';
271 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-cpt.php';
272 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-term-select.php';
273 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-shortcodes.php';
274 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-block.php';
275 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-ajax.php';
276 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-reindex.php';
277 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-assets.php';
278 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-single.php';
279 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-section-registry.php';
280 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-print.php';
281 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-shortcodes.php';
282 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-blocks.php';
283 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-elementor.php';
284 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-lead.php';
285 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/agent-sections.php';
286 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-schema.php';
287 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-metabox.php';
288 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-agent-metabox.php';
289 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-term-meta.php';
290 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-columns.php';
291 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-favorites.php';
292 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/page-block-registry.php';
293 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-shortcodes.php';
294 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-blocks.php';
295 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-elementor.php';
296 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-customizer.php'; // Standalone design settings in the WP Customizer (990 mode).
297 require_once plugin_dir_path( __FILE__ ) . 'includes/live/live-bootstrap.php'; // Live MLS passthrough mode (seam #1 — its only existing-file require).
298 add_action( 'init', array( 'Mlsimport_Standalone_Cpt', 'register' ) );
299 // After register(): drop cached rewrite rules when the standalone-mode signature changed (#206).
300 add_action( 'init', array( 'Mlsimport_Standalone_Cpt', 'maybe_flush_rewrites' ), 20 );
301 add_action( 'init', array( 'Mlsimport_Property_Metabox', 'register' ) );
302 add_action( 'init', array( 'Mlsimport_Agent_Metabox', 'register' ) );
303 add_action( 'init', array( 'Mlsimport_Term_Meta', 'register' ) );
304 add_action( 'init', array( 'Mlsimport_Property_Columns', 'register' ) );
305 add_action( 'init', array( 'Mlsimport_Standalone_Shortcodes', 'register' ) );
306 add_action( 'init', array( 'Mlsimport_Standalone_Block', 'register' ) );
307 add_action( 'init', array( 'Mlsimport_Standalone_Ajax', 'register' ) );
308 add_action( 'init', array( 'Mlsimport_Favorites', 'register' ) );
309 add_action( 'init', array( 'Mlsimport_Property_Section_Shortcodes', 'register' ) );
310 // Property-section Gutenberg blocks (mlsimport/property-*, "MLSImport — Property"
311 // category) are intentionally NOT registered — we don't expose them as blocks.
312 // The render dispatcher, shortcodes and Elementor widget behind them stay active.
313 // add_action( 'init', array( 'Mlsimport_Property_Section_Blocks', 'register' ) );
314 add_action( 'init', array( 'Mlsimport_Page_Block_Shortcodes', 'register' ) );
315 add_action( 'init', array( 'Mlsimport_Page_Block_Blocks', 'register' ) );
316 // Two MLSImport categories in the block inserter: single-property section blocks
317 // ("Property") and the page-builder blocks ("Real Estate").
318 add_filter(
319 'block_categories_all',
320 static function ( $categories ) {
321 return array_merge(
322 array(
323 array( 'slug' => 'mlsimport-property', 'title' => __( 'MLSImport — Property', 'mlsimport' ) ),
324 array( 'slug' => 'mlsimport-real-estate', 'title' => __( 'MLSImport — Real Estate', 'mlsimport' ) ),
325 ),
326 $categories
327 );
328 }
329 );
330 add_action( 'init', array( 'Mlsimport_Property_Lead', 'register' ) );
331 // Priority 20 (not the default 10) so the plugin's self-contained BEM stylesheets
332 // enqueue AFTER the active theme's own styles. Themes hook wp_enqueue_scripts at 10
333 // and, because plugins load before the theme, our default-10 callback would print
334 // FIRST — losing every equal-specificity tie to a theme rule that prints later.
335 // Hello Elementor's reset.css is the concrete case: it styles bare `button` /
336 // `[type=button]` (pink #c36, inline-block, width:auto), which ties our single-class
337 // `.mlsimport-*` button rules (0,1,0) and won on order, so the multiselect control,
338 // range/beds toggles, popup buttons, submit and the card heart all rendered narrow
339 // and pink. Printing after the theme lets our base rules win that tie. It stays a
340 // SINGLE class (0,1,0), so component state rules (:focus / :hover / .is-open at
341 // 0,1,1+) and any Elementor Style-tab control ({{WRAPPER}} … at 0,2,0+) still win —
342 // this only reclaims the tie against a generic theme reset, and never matches a
343 // non-plugin (e.g. Elementor) element.
344 add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Standalone_Assets', 'enqueue' ), 20 );
345 add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Property_Section_Assets', 'ensure_registered' ) );
346 // Pre-enqueue the single-property section assets into the <head>; the single
347 // template prints the header before any section renders, so the on-demand
348 // enqueue at render time lands in the footer and flashes unstyled (issue #172).
349 // Priority 20 for the same after-the-theme reason as the standalone assets above.
350 add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Property_Section_Assets', 'enqueue_for_single' ), 20 );
351 // Same for the single-agent page (issue #188).
352 add_action( 'wp_enqueue_scripts', 'mlsimport_agent_enqueue_for_single', 20 );
353 // Load the same front-end CSS into the block editor so the dynamic blocks'
354 // ServerSideRender previews match the front end (editor-guarded inside the method).
355 add_action( 'enqueue_block_assets', array( 'Mlsimport_Standalone_Assets', 'enqueue_editor' ) );
356 add_filter( 'template_include', array( 'Mlsimport_Standalone_Single', 'template_include' ) );
357 add_action( 'wp_head', 'mlsimport_property_print_schema' );
358 Mlsimport_Property_Section_Elementor::register();
359 Mlsimport_Page_Block_Elementor::register();
360 // A contact-form lead carries no property and no agent; route those general
361 // enquiries to the "Contact form recipients" setting (decision 3).
362 add_filter(
363 'mlsimport_property_lead_recipient',
364 static function ( $to, $property_id, $agent_id = 0 ) {
365 if ( $property_id || $agent_id ) {
366 return $to;
367 }
368 $recipients = trim( (string) mlsimport_standalone_option( 'contact_form_recipients', '' ) );
369 return '' !== $recipients ? $recipients : $to;
370 },
371 10,
372 3
373 );
374 add_action( 'admin_init', array( 'Mlsimport_Standalone_Table', 'maybe_upgrade' ) );
375 add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
376 // Trash/unpublish (not a permanent delete) must also drop the listings row, so the
377 // search index only ever holds published listings.
378 add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
379
380 if ( defined( 'WP_CLI' ) && WP_CLI ) {
381 WP_CLI::add_command(
382 'mlsimport reindex',
383 function () {
384 $rebuilt = Mlsimport_Standalone_Reindex::rebuild_all();
385 WP_CLI::success( "Reindexed {$rebuilt} standalone listings." );
386 }
387 );
388 }
389
390 if ( ! wp_next_scheduled( 'event_mls_import_auto' ) ) {
391 wp_schedule_event( time(), 'hourly', 'event_mls_import_auto' );
392 }
393
394
395 /**
396 * Scheduled event: Processes MLSimport items marked for cron processing, in memory-safe batches.
397 *
398 * This function is triggered by the 'event_mls_import_auto' action.
399 * It fetches mlsimport_item post IDs in small batches (not all at once!) to minimize memory usage.
400 * Only posts with meta 'mlsimport_item_stat_cron' = 1 are processed.
401 * For each item, calls mlsimport_saas_start_cron_links_per_item().
402 *
403 * Optimizations:
404 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
405 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
406 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
407 * - Skips processing if MLS is not connected or token is missing
408 *
409 * @return void
410 */
411 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
412 /**
413 * Scheduled event handler for MLS Import Auto (runs via WP Cron).
414 * Processes mlsimport_item posts in batches and logs memory usage.
415 */
416 function mlsimport_saas_event_mls_import_auto_function() {
417 global $mlsimport;
418
419 //error_log('[AutoCron] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
420
421 // Watchdog backstop (issue #199): a chunked manual import whose worker
422 // chain died is normally revived by the polled progress screen, but if
423 // the administrator closed that screen nothing else watches the run.
424 // Hourly cron picks it up here; revive() is a cheap no-op for anything
425 // that is not a silent, stalled manual run.
426 // Import-health checks (#208) run before anything can bail out below:
427 // detect a previous cron run that died mid-loop, and a manual run stuck
428 // at "Preparing". Both open one deduplicated internal incident.
429 mlsimport_cron_heartbeat_check();
430 mlsimport_import_health_watch_manual_run();
431
432 $mlsimport_active_lock = get_option( 'mlsimport_import_run_lock', array() );
433 if ( is_array( $mlsimport_active_lock ) && ! empty( $mlsimport_active_lock['task_id'] ) ) {
434 $mlsimport_revive = $mlsimport->admin->mlsimport_import_task_execution()->revive( (int) $mlsimport_active_lock['task_id'] );
435 if ( true === ( $mlsimport_revive['revived'] ?? false ) ) {
436 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' );
437 } elseif ( 'stalled' === ( $mlsimport_revive['reason'] ?? '' ) ) {
438 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' );
439 // The run was terminated as hopeless — tell the SaaS once (#208).
440 mlsimport_alert_open(
441 'import_stalled:' . (int) $mlsimport_active_lock['task_id'],
442 'import_stalled',
443 array( 'task_id' => (int) $mlsimport_active_lock['task_id'] )
444 );
445 }
446 }
447
448 // 0. Bail if a run is already in progress. Without this guard an overlapping
449 // cron fire processes the same listings in parallel, causing duplicate
450 // listing_key inserts and term_relationship/term_count deadlocks. The TTL is
451 // the safety net if a run dies mid-loop without reaching the release below.
452 if ( get_transient( 'mlsimport_cron_running' ) ) {
453 return;
454 }
455
456 // 1. Get the API token from transient - exit if not set
457 $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
458 //error_log('[AutoCron] After token fetch: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
459 if (trim($token) === '') {
460 // Silent exit made frozen sites undiagnosable (issue #207 finding 3):
461 // record the failed attempt with a real class before bailing.
462 mlsimport_telemetry_set( 'last_sync_failed', time() );
463 mlsimport_telemetry_set( 'last_sync_failed_code', 'no_token' );
464 //error_log('[AutoCron] No token, exiting.');
465 return;
466 }
467
468 // 2. Check if MLS connection is valid - exit if not
469 $is_mls_connected = get_option('mlsimport_connection_test', '');
470 //error_log('[AutoCron] After connection check: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
471 if ('yes' !== $is_mls_connected) {
472 // Same rule as the token exit above: a sync attempt that cannot run
473 // records why, so the heartbeat can surface it.
474 mlsimport_telemetry_set( 'last_sync_failed', time() );
475 mlsimport_telemetry_set( 'last_sync_failed_code', 'mls_not_connected' );
476 //error_log('[AutoCron] No valid connection, exiting.');
477 return;
478 }
479
480 // Claim the run lock now that we are committed to processing.
481 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
482
483 // Heartbeat (#208): record that a cron import is now running, so the next
484 // cron entry can tell a clean finish from a process that died mid-loop.
485 mlsimport_cron_heartbeat_start();
486
487 // Record sync attempt in telemetry
488 mlsimport_telemetry_bump( 'syncs' );
489 mlsimport_telemetry_set( 'last_sync_attempt', time() );
490
491 // 3. Set batch size for gathering and initialize loop variables
492 $batch_size = 100;
493 $paged = 1;
494 $total_processed = 0;
495
496 // 4. Gather every cron-enabled task id first (ids only — a few bytes each,
497 // still fetched in paged batches so the query never loads post objects).
498 // Gathering before processing is what makes fair ordering possible below.
499 $cron_task_ids = array();
500 do {
501 // Prepare query: only IDs, filter by meta key, batch, paged, no_found_rows speeds up query
502 $args = array(
503 'post_type' => 'mlsimport_item',
504 'post_status' => 'any',
505 'posts_per_page' => $batch_size,
506 'paged' => $paged,
507 'fields' => 'ids',
508 'meta_query' => array(
509 array(
510 'key' => 'mlsimport_item_stat_cron',
511 'value' => 1,
512 'compare' => '=',
513 ),
514 ),
515 'no_found_rows' => true,
516 );
517
518 // Get post IDs for this batch
519 $post_ids = get_posts($args);
520
521 // If nothing is returned, the gather is complete
522 if (empty($post_ids)) {
523 break;
524 }
525
526 foreach ($post_ids as $prop_id) {
527 $cron_task_ids[] = (int) $prop_id;
528 }
529
530 // Prepare next batch
531 $paged++;
532 unset($post_ids); // Free memory
533
534 } while (true);
535
536 // 5. Order by starvation (issue #203): the query above returns tasks in
537 // the same fixed order every hour, so when an early large task ate the
538 // whole cycle the bottom tasks were skipped run after run. Sorting by the
539 // last-sync watermark puts the longest-unsynced region first in line.
540 $cron_task_watermarks = array();
541 foreach ($cron_task_ids as $prop_id) {
542 $cron_task_watermarks[ $prop_id ] = (string) get_post_meta( $prop_id, 'mlsimport_last_date', true );
543 }
544 unset($cron_task_ids);
545
546 // 6. Process every task, most starved first
547 foreach (mlsimport_cron_task_order($cron_task_watermarks) as $prop_id) {
548 $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
549 mlsimport_debuglogs_per_plugin($logs);
550
551 // Call processing function for this item. The feed count it pulls
552 // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
553 $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
554
555 $total_processed++;
556
557 // Heartbeat (#208): measurable progress for the stuck-run check.
558 mlsimport_cron_heartbeat_progress( $total_processed );
559
560 // Free memory every 100 processed items
561 if ($total_processed % 100 === 0) {
562 gc_collect_cycles();
563 //error_log("[AutoCron] Processed {$total_processed} total, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
564 }
565 }
566
567 // Heartbeat (#208): clean finish — also resolves an open died-run incident.
568 mlsimport_cron_heartbeat_finish();
569
570 // Release the run lock so the next scheduled run can proceed.
571 delete_transient( 'mlsimport_cron_running' );
572
573 // last_sync_success is no longer stamped here (issue #207 finding 1): the
574 // end-of-loop stamp reported success even when every request failed, and
575 // never fired when a run died mid-loop. Each listings request now records
576 // its own outcome inside mlsimport_make_listing_requests().
577
578 //error_log('[AutoCron] Done, total processed: ' . $total_processed . ', end memory: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
579 }
580
581
582
583 /*
584 * Reconciliation Mechanism
585 *
586 *
587 *
588 **/
589
590 if ( ! wp_next_scheduled( 'mlsimport_reconciliation_event' ) ) {
591 wp_schedule_event( time(), 'daily', 'mlsimport_reconciliation_event' );
592 }
593
594 add_action( 'mlsimport_reconciliation_event', 'mlsimport_saas_reconciliation_event_function' );
595 add_action( 'mlsimport_reconciliation_retry_event', 'mlsimport_saas_reconciliation_event_function' );
596
597 if ( ! wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) {
598 wp_schedule_event( time(), 'daily', 'mlsimport_daily_telemetry_event' );
599 }
600
601 add_action( 'mlsimport_daily_telemetry_event', 'mlsimport_telemetry_run_daily' );
602
603
604 /*
605 * Force use of transient
606 *
607 *
608 *
609 **/
610
611 /**
612 * Filter callback stub that returns the transient value unchanged.
613 *
614 * Left as a pass-through hook point; the commented `return false;` would force
615 * a transient miss for debugging.
616 *
617 * @param mixed $value Incoming transient value.
618 * @return mixed The value unchanged.
619 */
620 function mlsimport_force_use_transient( $value ) {
621 return $value;
622 // return false;
623 }
624
625
626
627
628 // Instantiate the core orchestrator and register all admin/public hooks.
629 global $mlsimport;
630 $mlsimport = new Mlsimport();
631 $mlsimport->run();
632
633
634
635
636
637 // Map of supported theme IDs to human labels. 990 = standalone (no host theme
638 // dependency); 991-994 are the four supported real-estate themes.
639 $supported_theme = array(
640 990 => 'Standalone (any theme)',
641 991 => 'WpResidence',
642 992 => 'Houzez',
643 993 => 'Real Homes',
644 994 => 'Wpestate',
645
646 );
647
648 // Expose the theme map as a constant for use across the plugin.
649 define( 'MLSIMPORT_THEME', $supported_theme );
650
651 add_filter( 'action_scheduler_failure_period', 'mlsimport_saas_filter_timelimit' );
652 /**
653 * Raise the Action Scheduler failure timeout so long imports are not marked failed.
654 *
655 * @param int $time_limit Default failure period in seconds (unused).
656 * @return int Fixed failure period of 3000 seconds.
657 */
658 function mlsimport_saas_filter_timelimit( $time_limit ) {
659 return 3000;
660 }
661
662
663
664 /*
665 *
666 * Write logs
667 *
668 **/
669
670 /**
671 * Append a timestamped line to a per-type import log file (when logging is on).
672 *
673 * @param string|array $message Message to log; arrays are JSON-encoded.
674 * @param string $tip_import Log bucket: normal|cron|delete|server_cron.
675 * @return void
676 */
677 function mlsimport_saas_single_write_import_custom_logs( $message, $tip_import = 'normal' ) {
678 // Check if logging is enabled
679 $enable_logs = intval( get_option( 'mlsimport_disable_logs' ) );
680 // Bail unless the "logs enabled" option equals exactly 1.
681 if ( 1 !== $enable_logs) {
682 return;
683 }
684
685 // Encode array payloads to JSON so they can be written as text.
686 if ( is_array( $message ) ) {
687 $message = wp_json_encode( $message );
688 }
689
690 // Prefix the message with a UTC timestamp.
691 $formatted_message = gmdate( 'F j, Y, g:i a' ) . ' -> ' . $message;
692
693 // Determine the log file path based on the import type
694 $log_file_name = 'cron' === $tip_import ? 'cron_logs' :
695 ( 'delete' === $tip_import ? 'delete_logs' :
696 ( 'server_cron' === $tip_import ? 'server_cron_logs' : 'import_logs' ) );
697
698 // Construct the full path with a date suffix
699 $log_file_path = WP_PLUGIN_DIR . "/mlsimport/logs/{$log_file_name}-" . gmdate( 'Y-m-d' ) . '.log';
700
701 // Error handling for file operations
702 try {
703 // Check and create the directory for logs if it does not exist
704 $log_dir = dirname( $log_file_path );
705 if ( ! file_exists( $log_dir ) ) {
706 mkdir( $log_dir, 0755, true );
707 }
708
709 // Append the formatted message to the log file
710 file_put_contents( $log_file_path, $formatted_message, FILE_APPEND | LOCK_EX );
711 } catch ( Exception $e ) {
712 // Handle the exception, such as logging the error elsewhere or sending a notification
713 }
714 }
715
716
717
718 /*
719 *
720 *
721 * Write Status logs
722 *
723 *
724 **/
725
726
727
728 /**
729 * Legacy status-log writer (superseded by mlsimport_debuglogs_per_plugin()).
730 *
731 * Note: writes with LOCK_EX but no FILE_APPEND, so it overwrites status_logs.log
732 * on each call. Retained under the _old suffix; not called anywhere.
733 *
734 * @param string|array $message Message to log; arrays are JSON-encoded.
735 * @return void
736 */
737 function mlsimport_debuglogs_per_plugin_old( $message ) {
738
739 // Encode array payloads to JSON.
740 if ( is_array( $message ) ) {
741 $message = wp_json_encode( $message );
742 }
743
744 global $wp_filesystem;
745 if ( empty( $wp_filesystem ) ) {
746 require_once ABSPATH . '/wp-admin/includes/file.php';
747 WP_Filesystem();
748 }
749
750 $path_status = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
751 file_put_contents( $path_status, $message, LOCK_EX );
752 }
753 /**
754 * Append a status/debug line to logs/status_logs.log.
755 *
756 * @param string|array $message Message to log; arrays are JSON-encoded.
757 * @return void
758 */
759 function mlsimport_debuglogs_per_plugin( $message ) {
760
761 // Encode array payloads to JSON.
762 if ( is_array( $message ) ) {
763 $message = wp_json_encode( $message );
764 }
765
766 // Nothing to write for an empty message.
767 if ( empty( $message ) ) {
768 return; // Exit the function if there's nothing to log
769 }
770
771 // Target log file inside the plugin's logs/ directory.
772 $log_file_path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
773
774 // Check and create the directory for logs if it does not exist
775 $log_dir = dirname( $log_file_path );
776 if ( ! file_exists( $log_dir ) ) {
777 mkdir( $log_dir, 0755, true );
778 }
779
780 // Error handling for file operations
781 try {
782 // Append the message to the log file with a newline and acquire an exclusive lock during writing
783 file_put_contents( $log_file_path, $message . PHP_EOL, LOCK_EX );
784 } catch ( Exception $e ) {
785 // Handle the exception, such as logging the error elsewhere or sending a notification
786 }
787 }
788
789
790
791
792
793 /*
794 * Cron job trigger
795 *
796 *
797 *
798 **/
799
800
801 // */5 * * * * wget http://example.com/check */2
802 add_action( 'init', 'mlsimport_trigger_cron_job' );
803 /**
804 * Server-cron entry point hit via the ?mlsimport_cron=yes query parameter.
805 *
806 * Rate-limited to once every 2 hours. Note: the actual import call on the
807 * throttled branch is currently commented out, so this only writes a log line.
808 *
809 * @return void
810 */
811 function mlsimport_trigger_cron_job() {
812 // ?mlsimport_cron=yes
813 // Only proceed when the request carries mlsimport_cron=yes.
814 if ( isset( $_REQUEST['mlsimport_cron'] ) && 'yes' === sanitize_text_field( wp_unslash( $_REQUEST['mlsimport_cron'] ) ) ) {
815 // Timestamp of the previous server-cron run (0 if never run).
816 $last_run = intval( get_option( 'mlsimport_last_server_cron' ) );
817 // Current time.
818 $now = time();
819 // First-ever call: seed the last-run timestamp.
820 if ( 0 === intval($last_run) ) {
821 update_option( 'mlsimport_last_server_cron', $now );
822 }
823
824 // Only run if at least 2 hours have elapsed since the last run.
825 if ( $last_run < $now - ( 60 * 60 * 2 ) ) {
826 // Build the "triggered" log line and record the new run time.
827 $log = 'Server Cron Job triggered on ' . date( 'l jS \of F Y h:i:s A', $last_run ) . ' vs ' . gmdate( 'l jS \of F Y h:i:s A', $now ) . PHP_EOL;
828 // mlsimport_saas_event_mls_import_auto_function();
829 update_option( 'mlsimport_last_server_cron', $now );
830 } else {
831 $log = 'Server Cron Job Called but not triggered. Last run on ' . gmdate( 'l jS \of F Y h:i:s A', $last_run ) . ' vs ' . gmdate( 'l jS \of F Y h:i:s A', $now ) . PHP_EOL;
832 }
833
834 mlsimport_saas_single_write_import_custom_logs( $log, 'server_cron' );
835 }
836 }
837
838
839
840 /**
841 * Render the "Sign up for MLSImport" promo box (30-day free-trial CTA).
842 *
843 * Uses a WpResidence-specific affiliate URL when that theme is active.
844 *
845 * @return void
846 */
847 function mlsimport_show_signup() {
848 // Default sign-up URL.
849 $affiliate_url = 'https://mlsimport.com';
850 // Swap in the WpResidence affiliate/campaign link when that theme is active.
851 if ( function_exists( 'wp_estate_init' ) ) {
852 $affiliate_url = 'https://mlsimport.com/ref/1/?campaign=wpresidence';
853 }
854 // Output the promo markup.
855 ?>
856 <div class="mlsimport_signup">
857 <h3><?php esc_html_e('Import MLS Listings into your Real Estate website', 'mlsimport'); ?></h3>
858 <p><?php esc_html_e('Signup now and get 30-Days Free trial, no setup fee & cancel anytime at ', 'mlsimport'); ?><a href="https://mlsimport.com/mls-import-plugin-pricing/" target="_blank">MLSImport.com</a></p>
859 <a href="https://mlsimport.com/mls-import-plugin-pricing" class="button mlsimport_button mlsimport_signup_button" target="_blank"><?php esc_html_e('Create My Account', 'mlsimport'); ?></a>
860 </div>
861 <?php
862 }
863
864
865 //add_action('admin_init', 'force_recount_all_terms');
866 /**
867 * Maintenance utility: recalculate term counts for every taxonomy.
868 *
869 * Not hooked by default (the add_action above is commented out); intended to be
870 * run manually to fix drifted term counts. Echoes a completion message.
871 *
872 * @return void
873 */
874 function force_recount_all_terms() {
875 global $wpdb;
876
877 // Get all taxonomies
878 $taxonomies = get_taxonomies([], 'names');
879
880 // Recount terms for each registered taxonomy.
881 foreach ($taxonomies as $taxonomy) {
882 // Get all terms for the taxonomy
883 $terms = get_terms([
884 'taxonomy' => $taxonomy,
885 'hide_empty' => false, // Include terms with 0 count
886 'fields' => 'ids', // Get only the term IDs
887 ]);
888
889 if (!is_wp_error($terms) && !empty($terms)) {
890 // Get term_taxonomy_ids for these terms
891 $term_taxonomy_ids = $wpdb->get_col($wpdb->prepare(
892 "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id IN (" . implode(',', array_map('intval', $terms)) . ")"
893 ));
894
895 // Update term counts
896 if (!empty($term_taxonomy_ids)) {
897 wp_update_term_count_now($term_taxonomy_ids, $taxonomy);
898 }
899 }
900 }
901
902 echo "Term counts have been recalculated for all taxonomies.";
903 }
904
905
906 // The theme <select> builder lives in includes/mlsimport-theme-detection.php,
907 // next to mlsimport_resolve_theme_id() whose answer it renders (#242).
908
909
910
911 // mlsimport_save_account_callback() (wp_ajax_mlsimport_save_account) moved to
912 // includes/mlsimport-onboarding.php next to its sibling handler
913 // mlsimport_ajax_test_account_connection(), so both credential-save paths live
914 // together and are covered by tests/unit/save-account-token-purge-test.php.
915
916
917
918
919
920
921 add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
922 function mlsimport_save_mls_data_callback() {
923 check_ajax_referer('mlsimport_onboarding_nonce', 'security');
924
925 $options = get_option('mlsimport_admin_options', []);
926 $options = is_array( $options ) ? $options : array();
927 $previous_mls_id = isset( $options['mlsimport_mls_name'] )
928 ? (string) $options['mlsimport_mls_name']
929 : '';
930
931 foreach ($_POST as $key => $value) {
932 if (strpos($key, 'mlsimport_') === 0 && $key !== 'mlsimport_username' && $key !== 'mlsimport_password') {
933 $options[$key] = sanitize_text_field($value);
934 }
935 }
936
937 // An MLS change invalidates only state owned by the old selection. Provider
938 // credentials remain saved so returning to that provider restores its fields.
939 $new_mls_id = isset( $options['mlsimport_mls_name'] )
940 ? (string) $options['mlsimport_mls_name']
941 : '';
942 if ( $previous_mls_id !== $new_mls_id ) {
943 Mlsimport_Provider_Family::clear_active_state();
944 } else {
945 Mlsimport_Provider_Family::clear_access_tokens();
946 delete_option( 'mlsimport_connection_test' );
947 delete_option( 'mlsimport_mls_metadata_populated' );
948 }
949
950 update_option('mlsimport_admin_options', $options);
951
952 // Always test the newly saved selection. Reusing a prior "yes" flag could
953 // incorrectly report that a different MLS or changed credentials succeeded.
954 global $mlsimport;
955 $mlsimport->admin->mlsimport_saas_setting_up();
956 $mlsimport->admin->mlsimport_saas_check_mls_connection();
957 $is_mls_connected = get_option('mlsimport_connection_test', '');
958
959 ob_start();
960 if ('yes' === $is_mls_connected) {
961 ?>
962 <div class="mlsimport_warning mlsimport_validated">
963 <?php esc_html_e('You’re now connected to your MLS.', 'mlsimport'); ?>
964 </div>
965 <?php
966 } else {
967 ?>
968 <div class="mlsimport_warning">
969 <?php esc_html_e('The connection to your MLS was NOT successful. Please check the authentication token is correct and check your MLS Data Access Application is approved.', 'mlsimport'); ?>
970 </div>
971 <?php
972 }
973 $html = ob_get_clean();
974
975 wp_send_json_success([
976 'message' => __('MLS data saved', 'mlsimport'),
977 'html' => $html,
978 'connected' => $is_mls_connected === 'yes',
979 ]);
980 }
981