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.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 +757 -111 5.8.47.2 View file →
@@ -1,12 +1,12 @@
1 1 <?php
2 2 /**
3 3 * Plugin Name: MlsImport
4 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: 5.8.4
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.2
7 7 * Requires at least: 5.2
8 - * Requires PHP: 7.2
8 + * Requires PHP: 7.4
9 9 * License: GPLv3
10 10 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
11 11 * Author: MlsImport
12 12 * Text Domain: mlsimport
@@ -12,8 +12,27 @@
12 12 * Text Domain: mlsimport
13 13 * Domain Path: /languages
14 14 */
15 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 +
16 35 // If this file is called directly, abort.
17 36
18 37 if ( ! defined( 'WPINC' ) ) {
19 38 die;
@@ -19,18 +38,41 @@
19 38 die;
20 39 }
21 40
22 41
23 -define( 'MLSIMPORT_VERSION', '5.8.4' );
42 +// Current plugin version (kept in sync with the header above and the readme).
43 +define( 'MLSIMPORT_VERSION', '7.2');
44 +// Marketing/portal host used to build sign-up and affiliate links.
24 45 define( 'MLSIMPORT_CLUBLINK', 'mlsimport.com' );
46 +// Scheme for the portal host links.
25 47 define( 'MLSIMPORT_CLUBLINKSSL', 'https' );
48 +// Default per-request import batch size (listings pulled per cron step).
26 49 define( 'MLSIMPORT_CRON_STEP', 20 );
50 +// Absolute filesystem path to this plugin directory (trailing slash).
27 51 define( 'MLSIMPORT_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
52 +// Public URL to this plugin directory (trailing slash).
28 53 define( 'MLSIMPORT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
29 -define( 'MLSIMPORT_API_URL', 'https://requests.mlsimport.com/' );
30 54
31 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/');
32 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 +
33 75 /**
34 76 * The code that runs during plugin activation.
35 77 * This action is documented in includes/class-mlsimport-activator.php
36 78 */
@@ -36,8 +78,13 @@
36 78 */
37 79 function mlsimport_activate() {
38 80 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-activator.php';
39 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();
40 87 }
41 88
42 89
43 90
@@ -48,8 +95,11 @@
48 95 function mlsimport_deactivate() {
49 96 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-deactivator.php';
50 97 wp_clear_scheduled_hook( 'event_mls_import_auto' );
51 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' );
52 102 Mlsimport_Deactivator::deactivate();
53 103 }
54 104
55 105
@@ -56,9 +106,86 @@
56 106
57 107 register_activation_hook( __FILE__, 'mlsimport_activate' );
58 108 register_deactivation_hook( __FILE__, 'mlsimport_deactivate' );
59 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 +}
60 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 +}
61 188
62 189 /**
63 190 * The core plugin class that is used to define internationalization,
64 191 * admin-specific hooks, and public-facing site hooks.
@@ -63,12 +190,97 @@
63 190 * The core plugin class that is used to define internationalization,
64 191 * admin-specific hooks, and public-facing site hooks.
65 192 */
66 193
67 -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';
212 +// Core includes: RESO field defs + helpers, provider map, cron/reconciliation
213 +// guards, status taxonomy/normalizer, then the orchestrator and API client.
68 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/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';
69 280 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
70 281 require_once plugin_dir_path( __FILE__ ) . 'includes/ThemeImport.php';
282 +require_once plugin_dir_path( __FILE__ ) . 'enviroment/StandaloneClass.php';
71 283 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResidenceClass.php';
72 284 require_once plugin_dir_path( __FILE__ ) . 'enviroment/EstateClass.php';
73 285 require_once plugin_dir_path( __FILE__ ) . 'enviroment/HouzezClass.php';
74 286 require_once plugin_dir_path( __FILE__ ) . 'enviroment/RealHomesClass.php';
@@ -76,62 +288,389 @@
76 288 require_once plugin_dir_path( __FILE__ ) . 'enviroment/SparkResoClass.php';
77 289 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BridgeResoClass.php';
78 290 require_once plugin_dir_path( __FILE__ ) . 'enviroment/TresleResoClass.php';
79 291 require_once plugin_dir_path( __FILE__ ) . 'enviroment/MlsgridResoClass.php';
80 -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';
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';
300 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
81 301
302 +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
303 +require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
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';
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';
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';
82 316
317 +/*
318 + * Standalone (theme_id 990) mode — own listings table, CPTs and taxonomies.
319 + * Registered on every load (ADR-0003); the table is created on activation and
320 + * upgraded behind a version guard on admin_init.
321 + */
322 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/mlsimport-hooks.php'; // Hook reference + convention (loaded early).
323 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-table.php';
324 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-cpt.php';
325 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-term-select.php';
326 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-shortcodes.php';
327 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-block.php';
328 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-ajax.php';
329 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-reindex.php';
330 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-assets.php';
331 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-single.php';
332 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-section-registry.php';
333 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-print.php';
334 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-shortcodes.php';
335 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-blocks.php';
336 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-elementor.php';
337 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-lead.php';
338 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/agent-sections.php';
339 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-schema.php';
340 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-metabox.php';
341 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-agent-metabox.php';
342 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-term-meta.php';
343 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-columns.php';
344 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-favorites.php';
345 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/page-block-registry.php';
346 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-shortcodes.php';
347 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-blocks.php';
348 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-elementor.php';
349 +require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-customizer.php'; // Standalone design settings in the WP Customizer (990 mode).
350 +require_once plugin_dir_path( __FILE__ ) . 'includes/live/live-bootstrap.php'; // Live MLS passthrough mode (seam #1 — its only existing-file require).
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 );
354 +add_action( 'init', array( 'Mlsimport_Property_Metabox', 'register' ) );
355 +add_action( 'init', array( 'Mlsimport_Agent_Metabox', 'register' ) );
356 +add_action( 'init', array( 'Mlsimport_Term_Meta', 'register' ) );
357 +add_action( 'init', array( 'Mlsimport_Property_Columns', 'register' ) );
358 +add_action( 'init', array( 'Mlsimport_Standalone_Shortcodes', 'register' ) );
359 +add_action( 'init', array( 'Mlsimport_Standalone_Block', 'register' ) );
360 +add_action( 'init', array( 'Mlsimport_Standalone_Ajax', 'register' ) );
361 +add_action( 'init', array( 'Mlsimport_Favorites', 'register' ) );
362 +add_action( 'init', array( 'Mlsimport_Property_Section_Shortcodes', 'register' ) );
363 +// Property-section Gutenberg blocks (mlsimport/property-*, "MLSImport — Property"
364 +// category) are intentionally NOT registered — we don't expose them as blocks.
365 +// The render dispatcher, shortcodes and Elementor widget behind them stay active.
366 +// add_action( 'init', array( 'Mlsimport_Property_Section_Blocks', 'register' ) );
367 +add_action( 'init', array( 'Mlsimport_Page_Block_Shortcodes', 'register' ) );
368 +add_action( 'init', array( 'Mlsimport_Page_Block_Blocks', 'register' ) );
369 +// Two MLSImport categories in the block inserter: single-property section blocks
370 +// ("Property") and the page-builder blocks ("Real Estate").
371 +add_filter(
372 + 'block_categories_all',
373 + static function ( $categories ) {
374 + return array_merge(
375 + array(
376 + array( 'slug' => 'mlsimport-property', 'title' => __( 'MLSImport — Property', 'mlsimport' ) ),
377 + array( 'slug' => 'mlsimport-real-estate', 'title' => __( 'MLSImport — Real Estate', 'mlsimport' ) ),
378 + ),
379 + $categories
380 + );
381 + }
382 +);
383 +add_action( 'init', array( 'Mlsimport_Property_Lead', 'register' ) );
384 +// Priority 20 (not the default 10) so the plugin's self-contained BEM stylesheets
385 +// enqueue AFTER the active theme's own styles. Themes hook wp_enqueue_scripts at 10
386 +// and, because plugins load before the theme, our default-10 callback would print
387 +// FIRST — losing every equal-specificity tie to a theme rule that prints later.
388 +// Hello Elementor's reset.css is the concrete case: it styles bare `button` /
389 +// `[type=button]` (pink #c36, inline-block, width:auto), which ties our single-class
390 +// `.mlsimport-*` button rules (0,1,0) and won on order, so the multiselect control,
391 +// range/beds toggles, popup buttons, submit and the card heart all rendered narrow
392 +// and pink. Printing after the theme lets our base rules win that tie. It stays a
393 +// SINGLE class (0,1,0), so component state rules (:focus / :hover / .is-open at
394 +// 0,1,1+) and any Elementor Style-tab control ({{WRAPPER}} … at 0,2,0+) still win —
395 +// this only reclaims the tie against a generic theme reset, and never matches a
396 +// non-plugin (e.g. Elementor) element.
397 +add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Standalone_Assets', 'enqueue' ), 20 );
398 +add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Property_Section_Assets', 'ensure_registered' ) );
399 +// Pre-enqueue the single-property section assets into the <head>; the single
400 +// template prints the header before any section renders, so the on-demand
401 +// enqueue at render time lands in the footer and flashes unstyled (issue #172).
402 +// Priority 20 for the same after-the-theme reason as the standalone assets above.
403 +add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Property_Section_Assets', 'enqueue_for_single' ), 20 );
404 +// Same for the single-agent page (issue #188).
405 +add_action( 'wp_enqueue_scripts', 'mlsimport_agent_enqueue_for_single', 20 );
406 +// Load the same front-end CSS into the block editor so the dynamic blocks'
407 +// ServerSideRender previews match the front end (editor-guarded inside the method).
408 +add_action( 'enqueue_block_assets', array( 'Mlsimport_Standalone_Assets', 'enqueue_editor' ) );
409 +add_filter( 'template_include', array( 'Mlsimport_Standalone_Single', 'template_include' ) );
410 +add_action( 'wp_head', 'mlsimport_property_print_schema' );
411 +Mlsimport_Property_Section_Elementor::register();
412 +Mlsimport_Page_Block_Elementor::register();
413 +// A contact-form lead carries no property and no agent; route those general
414 +// enquiries to the "Contact form recipients" setting (decision 3).
415 +add_filter(
416 + 'mlsimport_property_lead_recipient',
417 + static function ( $to, $property_id, $agent_id = 0 ) {
418 + if ( $property_id || $agent_id ) {
419 + return $to;
420 + }
421 + $recipients = trim( (string) mlsimport_standalone_option( 'contact_form_recipients', '' ) );
422 + return '' !== $recipients ? $recipients : $to;
423 + },
424 + 10,
425 + 3
426 +);
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' ) );
430 +add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
431 +// Trash/unpublish (not a permanent delete) must also drop the listings row, so the
432 +// search index only ever holds published listings.
433 +add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
83 434
435 +if ( defined( 'WP_CLI' ) && WP_CLI ) {
436 + WP_CLI::add_command(
437 + 'mlsimport reindex',
438 + function () {
439 + $rebuilt = Mlsimport_Standalone_Reindex::rebuild_all();
440 + WP_CLI::success( "Reindexed {$rebuilt} standalone listings." );
441 + }
442 + );
443 +}
444 +
84 445 if ( ! wp_next_scheduled( 'event_mls_import_auto' ) ) {
85 446 wp_schedule_event( time(), 'hourly', 'event_mls_import_auto' );
86 447 }
87 448
88 449
450 +/**
451 + * Scheduled event: Processes MLSimport items marked for cron processing, in memory-safe batches.
452 + *
453 + * This function is triggered by the 'event_mls_import_auto' action.
454 + * It fetches mlsimport_item post IDs in small batches (not all at once!) to minimize memory usage.
455 + * Only posts with meta 'mlsimport_item_stat_cron' = 1 are processed.
456 + * For each item, calls mlsimport_saas_start_cron_links_per_item().
457 + *
458 + * Optimizations:
459 + * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
460 + * - Batches with posts_per_page/paged, so memory does not spike for large data sets
461 + * - Calls gc_collect_cycles() periodically to further reduce memory leaks
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
465 + *
466 + * @return void
467 + */
468 +add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
469 +/**
470 + * Scheduled event handler for MLS Import Auto (runs via WP Cron).
471 + * Processes mlsimport_item posts in batches and logs memory usage.
472 + */
473 +function mlsimport_saas_event_mls_import_auto_function() {
474 + global $mlsimport;
89 475
476 + //error_log('[AutoCron] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
90 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();
91 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 + }
92 504
505 + // 0. Bail if a run is already in progress. Without this guard an overlapping
506 + // cron fire processes the same listings in parallel, causing duplicate
507 + // listing_key inserts and term_relationship/term_count deadlocks. The TTL is
508 + // the safety net if a run dies mid-loop without reaching the release below.
509 + if ( get_transient( 'mlsimport_cron_running' ) ) {
510 + return;
511 + }
93 512
94 -add_action( 'event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function' );
95 -function mlsimport_saas_event_mls_import_auto_function() {
96 - global $mlsimport;
97 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
98 - if ( trim( $token ) === '' ) {
99 - return;
100 - }
513 + // 1. Get the API token from transient - exit if not set
514 + $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
515 + //error_log('[AutoCron] After token fetch: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
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' );
521 + //error_log('[AutoCron] No token, exiting.');
522 + return;
523 + }
101 524
102 - $is_mls_connected = get_option( 'mlsimport_connection_test', '' );
103 - if ( 'yes' !== $is_mls_connected ) {
104 - return;
105 - }
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.
106 529
107 - $logs ='';
108 - mlsimport_debuglogs_per_plugin( $logs );
109 - $args = array(
110 - 'post_type' => 'mlsimport_item',
111 - 'post_status' => 'any',
112 - 'posts_per_page' => -1,
113 - 'meta_query' => array(
114 - array(
115 - 'key' => 'mlsimport_item_stat_cron',
116 - 'value' => 1,
117 - 'compare' => '=',
118 - ),
530 + // Claim the run lock now that we are committed to processing.
531 + set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
119 532
120 - ),
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();
121 541
122 - );
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.
548 + mlsimport_telemetry_set( 'last_sync_attempt', time() );
123 549
124 - $prop_selection = new WP_Query( $args );
125 - if ( $prop_selection->have_posts() ) {
126 - while ( $prop_selection->have_posts() ) :
127 - $prop_selection->the_post();
128 - $prop_id = get_the_ID();
129 - $logs = ' Loop custom post : ' . $prop_id . PHP_EOL;
130 - mlsimport_debuglogs_per_plugin( $logs );
131 - $mlsimport->admin->mlsimport_saas_start_cron_links_per_item( $prop_id );
132 - endwhile;
133 - }
550 + // 3. Set batch size for gathering and initialize loop variables
551 + $batch_size = 100;
552 + $paged = 1;
553 + $total_processed = 0;
554 +
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();
559 + do {
560 + // Prepare query: only IDs, filter by meta key, batch, paged, no_found_rows speeds up query
561 + $args = array(
562 + 'post_type' => 'mlsimport_item',
563 + 'post_status' => 'any',
564 + 'posts_per_page' => $batch_size,
565 + 'paged' => $paged,
566 + 'fields' => 'ids',
567 + 'meta_query' => array(
568 + array(
569 + 'key' => 'mlsimport_item_stat_cron',
570 + 'value' => 1,
571 + 'compare' => '=',
572 + ),
573 + ),
574 + 'no_found_rows' => true,
575 + );
576 +
577 + // Get post IDs for this batch
578 + $post_ids = get_posts($args);
579 +
580 + // If nothing is returned, the gather is complete
581 + if (empty($post_ids)) {
582 + break;
583 + }
584 +
585 + foreach ($post_ids as $prop_id) {
586 + $cron_task_ids[] = (int) $prop_id;
587 + }
588 +
589 + // Prepare next batch
590 + $paged++;
591 + unset($post_ids); // Free memory
592 +
593 + } while (true);
594 +
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'] );
634 + }
635 + mlsimport_debuglogs_per_plugin(
636 + 'Task ' . $prop_id . ' skipped: connection ' . $mlsimport_gate['mls_id']
637 + . ' ' . $mlsimport_gate['action'] . ' (' . $mlsimport_gate['code'] . ')' . PHP_EOL
638 + );
639 + continue;
640 + }
641 +
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);
645 +
646 + $total_processed++;
647 +
648 + // Heartbeat (#208): measurable progress for the stuck-run check.
649 + mlsimport_cron_heartbeat_progress( $total_processed );
650 +
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 + }
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 +
672 + //error_log('[AutoCron] Done, total processed: ' . $total_processed . ', end memory: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
134 673 }
135 674
136 675
137 676
@@ -146,10 +685,17 @@
146 685 wp_schedule_event( time(), 'daily', 'mlsimport_reconciliation_event' );
147 686 }
148 687
149 688 add_action( 'mlsimport_reconciliation_event', 'mlsimport_saas_reconciliation_event_function' );
689 +add_action( 'mlsimport_reconciliation_retry_event', 'mlsimport_saas_reconciliation_event_function' );
150 690
691 +if ( ! wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) {
692 + wp_schedule_event( time(), 'daily', 'mlsimport_daily_telemetry_event' );
693 +}
151 694
695 +add_action( 'mlsimport_daily_telemetry_event', 'mlsimport_telemetry_run_daily' );
696 +
697 +
152 698 /*
153 699 * Force use of transient
154 700 *
155 701 *
@@ -155,8 +701,17 @@
155 701 *
156 702 *
157 703 **/
158 704
705 +/**
706 + * Filter callback stub that returns the transient value unchanged.
707 + *
708 + * Left as a pass-through hook point; the commented `return false;` would force
709 + * a transient miss for debugging.
710 + *
711 + * @param mixed $value Incoming transient value.
712 + * @return mixed The value unchanged.
713 + */
159 714 function mlsimport_force_use_transient( $value ) {
160 715 return $value;
161 716 // return false;
162 717 }
@@ -163,8 +718,9 @@
163 718
164 719
165 720
166 721
722 +// Instantiate the core orchestrator and register all admin/public hooks.
167 723 global $mlsimport;
168 724 $mlsimport = new Mlsimport();
169 725 $mlsimport->run();
170 726
@@ -171,9 +727,12 @@
171 727
172 728
173 729
174 730
731 +// Map of supported theme IDs to human labels. 990 = standalone (no host theme
732 +// dependency); 991-994 are the four supported real-estate themes.
175 733 $supported_theme = array(
734 + 990 => 'Standalone (any theme)',
176 735 991 => 'WpResidence',
177 736 992 => 'Houzez',
178 737 993 => 'Real Homes',
179 738 994 => 'Wpestate',
@@ -179,11 +738,18 @@
179 738 994 => 'Wpestate',
180 739
181 740 );
182 741
742 +// Expose the theme map as a constant for use across the plugin.
183 743 define( 'MLSIMPORT_THEME', $supported_theme );
184 744
185 745 add_filter( 'action_scheduler_failure_period', 'mlsimport_saas_filter_timelimit' );
746 +/**
747 + * Raise the Action Scheduler failure timeout so long imports are not marked failed.
748 + *
749 + * @param int $time_limit Default failure period in seconds (unused).
750 + * @return int Fixed failure period of 3000 seconds.
751 + */
186 752 function mlsimport_saas_filter_timelimit( $time_limit ) {
187 753 return 3000;
188 754 }
189 755
@@ -194,19 +760,29 @@
194 760 * Write logs
195 761 *
196 762 **/
197 763
764 +/**
765 + * Append a timestamped line to a per-type import log file (when logging is on).
766 + *
767 + * @param string|array $message Message to log; arrays are JSON-encoded.
768 + * @param string $tip_import Log bucket: normal|cron|delete|server_cron.
769 + * @return void
770 + */
198 771 function mlsimport_saas_single_write_import_custom_logs( $message, $tip_import = 'normal' ) {
199 772 // Check if logging is enabled
200 773 $enable_logs = intval( get_option( 'mlsimport_disable_logs' ) );
774 + // Bail unless the "logs enabled" option equals exactly 1.
201 775 if ( 1 !== $enable_logs) {
202 776 return;
203 777 }
204 778
779 + // Encode array payloads to JSON so they can be written as text.
205 780 if ( is_array( $message ) ) {
206 781 $message = wp_json_encode( $message );
207 782 }
208 783
784 + // Prefix the message with a UTC timestamp.
209 785 $formatted_message = gmdate( 'F j, Y, g:i a' ) . ' -> ' . $message;
210 786
211 787 // Determine the log file path based on the import type
212 788 $log_file_name = 'cron' === $tip_import ? 'cron_logs' :
@@ -242,10 +818,20 @@
242 818 **/
243 819
244 820
245 821
822 +/**
823 + * Legacy status-log writer (superseded by mlsimport_debuglogs_per_plugin()).
824 + *
825 + * Note: writes with LOCK_EX but no FILE_APPEND, so it overwrites status_logs.log
826 + * on each call. Retained under the _old suffix; not called anywhere.
827 + *
828 + * @param string|array $message Message to log; arrays are JSON-encoded.
829 + * @return void
830 + */
246 831 function mlsimport_debuglogs_per_plugin_old( $message ) {
247 832
833 + // Encode array payloads to JSON.
248 834 if ( is_array( $message ) ) {
249 835 $message = wp_json_encode( $message );
250 836 }
251 837
@@ -257,18 +843,27 @@
257 843
258 844 $path_status = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
259 845 file_put_contents( $path_status, $message, LOCK_EX );
260 846 }
847 +/**
848 + * Append a status/debug line to logs/status_logs.log.
849 + *
850 + * @param string|array $message Message to log; arrays are JSON-encoded.
851 + * @return void
852 + */
261 853 function mlsimport_debuglogs_per_plugin( $message ) {
262 854
855 + // Encode array payloads to JSON.
263 856 if ( is_array( $message ) ) {
264 857 $message = wp_json_encode( $message );
265 858 }
266 859
860 + // Nothing to write for an empty message.
267 861 if ( empty( $message ) ) {
268 862 return; // Exit the function if there's nothing to log
269 863 }
270 864
865 + // Target log file inside the plugin's logs/ directory.
271 866 $log_file_path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
272 867
273 868 // Check and create the directory for logs if it does not exist
274 869 $log_dir = dirname( $log_file_path );
@@ -298,18 +893,32 @@
298 893
299 894
300 895 // */5 * * * * wget http://example.com/check */2
301 896 add_action( 'init', 'mlsimport_trigger_cron_job' );
897 +/**
898 + * Server-cron entry point hit via the ?mlsimport_cron=yes query parameter.
899 + *
900 + * Rate-limited to once every 2 hours. Note: the actual import call on the
901 + * throttled branch is currently commented out, so this only writes a log line.
902 + *
903 + * @return void
904 + */
302 905 function mlsimport_trigger_cron_job() {
303 906 // ?mlsimport_cron=yes
907 + // Only proceed when the request carries mlsimport_cron=yes.
304 908 if ( isset( $_REQUEST['mlsimport_cron'] ) && 'yes' === sanitize_text_field( wp_unslash( $_REQUEST['mlsimport_cron'] ) ) ) {
909 + // Timestamp of the previous server-cron run (0 if never run).
305 910 $last_run = intval( get_option( 'mlsimport_last_server_cron' ) );
911 + // Current time.
306 912 $now = time();
913 + // First-ever call: seed the last-run timestamp.
307 914 if ( 0 === intval($last_run) ) {
308 915 update_option( 'mlsimport_last_server_cron', $now );
309 916 }
310 917
918 + // Only run if at least 2 hours have elapsed since the last run.
311 919 if ( $last_run < $now - ( 60 * 60 * 2 ) ) {
920 + // Build the "triggered" log line and record the new run time.
312 921 $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;
313 922 // mlsimport_saas_event_mls_import_auto_function();
314 923 update_option( 'mlsimport_last_server_cron', $now );
315 924 } else {
@@ -321,90 +930,42 @@
321 930 }
322 931
323 932
324 933
934 +/**
935 + * Render the "Sign up for MLSImport" promo box (30-day free-trial CTA).
936 + *
937 + * Uses a WpResidence-specific affiliate URL when that theme is active.
938 + *
939 + * @return void
940 + */
325 941 function mlsimport_show_signup() {
942 + // Default sign-up URL.
326 943 $affiliate_url = 'https://mlsimport.com';
944 + // Swap in the WpResidence affiliate/campaign link when that theme is active.
327 945 if ( function_exists( 'wp_estate_init' ) ) {
328 946 $affiliate_url = 'https://mlsimport.com/ref/1/?campaign=wpresidence';
329 947 }
948 + // Output the promo markup.
330 949 ?>
331 950 <div class="mlsimport_signup">
332 951 <h3><?php esc_html_e('Import MLS Listings into your Real Estate website', 'mlsimport'); ?></h3>
333 - <p><?php esc_html_e('Signup now and get 30-Days Free trial, no setup fee & cancel anytime at ', 'mlsimport'); ?><a href="<?php echo esc_url($affiliate_url); ?>" target="_blank">MlsImport.com</a></p>
334 - <a href="<?php echo esc_url($affiliate_url); ?>" class="button mlsimport_button mlsimport_signup_button" target="_blank"><?php esc_html_e('Create My Account', 'mlsimport'); ?></a>
952 + <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>
953 + <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>
335 954 </div>
336 955 <?php
337 956 }
338 957
339 -/*
340 958
341 -// Register the hook before calling the scheduling function
342 -add_action('mlsimport_delete_empty_terms_batch_event', 'mlsimport_delete_empty_terms_batch', 10, 3);
343 -
344 -// Call this function with your taxonomy to start the deletion process
345 -mlsimport_schedule_empty_terms_deletion('property_area');
346 -
347 -function mlsimport_schedule_empty_terms_deletion($taxonomy) {
348 - // Clear any existing scheduled events
349 - wp_clear_scheduled_hook('mlsimport_delete_empty_terms_batch_event');
350 -
351 - // Schedule the first batch
352 - if (!wp_next_scheduled('mlsimport_delete_empty_terms_batch_event', array($taxonomy, 100, 0))) {
353 - wp_schedule_single_event(time() + 30, 'mlsimport_delete_empty_terms_batch_event', array($taxonomy, 100, 0));
354 - echo 'Scheduled first batch event.<br>';
355 - } else {
356 - echo 'First batch event already scheduled.<br>';
357 - }
358 -}
359 -
360 -function mlsimport_delete_empty_terms_batch($taxonomy, $batch_size = 100, $offset = 0) {
361 - // Debugging output to confirm function execution
362 - echo 'Processing batch starting from offset: ' . $offset . '<br>';
363 -
364 - // Get terms in batches
365 - $terms = get_terms(array(
366 - 'taxonomy' => $taxonomy,
367 - 'hide_empty' => false, // Include empty terms
368 - 'orderby' => 'count',
369 - 'order' => 'ASC',
370 - 'number' => $batch_size,
371 - 'offset' => $offset,
372 - ));
373 -
374 - if (!is_wp_error($terms) && !empty($terms)) {
375 - foreach ($terms as $term) {
376 - // Check if term count is zero
377 - if ($term->count == 0) {
378 - // Delete the term if it has no posts associated
379 - wp_delete_term($term->term_id, $taxonomy);
380 - echo 'Deleted term ID: ' . $term->term_id . '<br>';
381 - }
382 - }
383 -
384 - // Schedule the next batch if more terms are found
385 - if (count($terms) == $batch_size) {
386 - $next_offset = $offset + $batch_size;
387 - wp_schedule_single_event(time() + 30, 'mlsimport_delete_empty_terms_batch_event', array($taxonomy, $batch_size, $next_offset));
388 - echo 'Scheduled next batch event from offset: ' . $next_offset . '<br>';
389 - } else {
390 - echo 'No more terms to process.<br>';
391 - }
392 - } else {
393 - // Debugging output for error or completion
394 - if (is_wp_error($terms)) {
395 - echo 'Error fetching terms: ' . $terms->get_error_message() . '<br>';
396 - } else {
397 - echo 'No more terms to process.<br>';
398 - }
399 - }
400 -}
401 -
402 -
403 -*/
404 -
405 -
406 959 //add_action('admin_init', 'force_recount_all_terms');
960 +/**
961 + * Maintenance utility: recalculate term counts for every taxonomy.
962 + *
963 + * Not hooked by default (the add_action above is commented out); intended to be
964 + * run manually to fix drifted term counts. Echoes a completion message.
965 + *
966 + * @return void
967 + */
407 968 function force_recount_all_terms() {
408 969 global $wpdb;
409 970
410 971 // Get all taxonomies
@@ -409,8 +970,9 @@
409 970
410 971 // Get all taxonomies
411 972 $taxonomies = get_taxonomies([], 'names');
412 973
974 + // Recount terms for each registered taxonomy.
413 975 foreach ($taxonomies as $taxonomy) {
414 976 // Get all terms for the taxonomy
415 977 $terms = get_terms([
416 978 'taxonomy' => $taxonomy,
@@ -431,5 +993,89 @@
431 993 }
432 994 }
433 995
434 996 echo "Term counts have been recalculated for all taxonomies.";
435 -}
997 +}
998 +
999 +
1000 +// The theme <select> builder lives in includes/mlsimport-theme-detection.php,
1001 +// next to mlsimport_resolve_theme_id() whose answer it renders (#242).
1002 +
1003 +
1004 +
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.
1009 +
1010 +
1011 +
1012 +
1013 +
1014 +
1015 +add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
1016 +function mlsimport_save_mls_data_callback() {
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 + }
1021 +
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 + : '';
1027 +
1028 + foreach ($_POST as $key => $value) {
1029 + if (strpos($key, 'mlsimport_') === 0 && $key !== 'mlsimport_username' && $key !== 'mlsimport_password') {
1030 + $options[$key] = sanitize_text_field($value);
1031 + }
1032 + }
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 +
1051 + update_option('mlsimport_admin_options', $options);
1052 +
1053 + // Always test the newly saved selection. Reusing a prior "yes" flag could
1054 + // incorrectly report that a different MLS or changed credentials succeeded.
1055 + global $mlsimport;
1056 + $mlsimport->admin->mlsimport_saas_setting_up();
1057 + $mlsimport->admin->mlsimport_saas_check_mls_connection();
1058 + $is_mls_connected = get_option('mlsimport_connection_test', '');
1059 +
1060 + ob_start();
1061 + if ('yes' === $is_mls_connected) {
1062 + ?>
1063 + <div class="mlsimport_warning mlsimport_validated">
1064 + <?php esc_html_e('You’re now connected to your MLS.', 'mlsimport'); ?>
1065 + </div>
1066 + <?php
1067 + } else {
1068 + ?>
1069 + <div class="mlsimport_warning">
1070 + <?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'); ?>
1071 + </div>
1072 + <?php
1073 + }
1074 + $html = ob_get_clean();
1075 +
1076 + wp_send_json_success([
1077 + 'message' => __('MLS data saved', 'mlsimport'),
1078 + 'html' => $html,
1079 + 'connected' => $is_mls_connected === 'yes',
1080 + ]);
1081 +}