PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 6.0.7 All 35 releases
mlsimport / mlsimport.php

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

1,082 lines 50.1 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.2
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.2');
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/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';
280 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
281 require_once plugin_dir_path( __FILE__ ) . 'includes/ThemeImport.php';
282 require_once plugin_dir_path( __FILE__ ) . 'enviroment/StandaloneClass.php';
283 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResidenceClass.php';
284 require_once plugin_dir_path( __FILE__ ) . 'enviroment/EstateClass.php';
285 require_once plugin_dir_path( __FILE__ ) . 'enviroment/HouzezClass.php';
286 require_once plugin_dir_path( __FILE__ ) . 'enviroment/RealHomesClass.php';
287 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResoBase.php';
288 require_once plugin_dir_path( __FILE__ ) . 'enviroment/SparkResoClass.php';
289 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BridgeResoClass.php';
290 require_once plugin_dir_path( __FILE__ ) . 'enviroment/TresleResoClass.php';
291 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';
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';
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 );
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
445 if ( ! wp_next_scheduled( 'event_mls_import_auto' ) ) {
446 wp_schedule_event( time(), 'hourly', 'event_mls_import_auto' );
447 }
448
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;
475
476 //error_log('[AutoCron] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
477
478 // Watchdog backstop (issue #199): a chunked manual import whose worker
479 // chain died is normally revived by the polled progress screen, but if
480 // the administrator closed that screen nothing else watches the run.
481 // Hourly cron picks it up here; revive() is a cheap no-op for anything
482 // that is not a silent, stalled manual run.
483 // Import-health checks (#208) run before anything can bail out below:
484 // detect a previous cron run that died mid-loop, and a manual run stuck
485 // at "Preparing". Both open one deduplicated internal incident.
486 mlsimport_cron_heartbeat_check();
487 mlsimport_import_health_watch_manual_run();
488
489 $mlsimport_active_lock = get_option( 'mlsimport_import_run_lock', array() );
490 if ( is_array( $mlsimport_active_lock ) && ! empty( $mlsimport_active_lock['task_id'] ) ) {
491 $mlsimport_revive = $mlsimport->admin->mlsimport_import_task_execution()->revive( (int) $mlsimport_active_lock['task_id'] );
492 if ( true === ( $mlsimport_revive['revived'] ?? false ) ) {
493 mlsimport_saas_single_write_import_custom_logs( 'Hourly watchdog revived the import worker chain for task ' . (int) $mlsimport_active_lock['task_id'] . '.' . PHP_EOL, 'manual' );
494 } elseif ( 'stalled' === ( $mlsimport_revive['reason'] ?? '' ) ) {
495 mlsimport_saas_single_write_import_custom_logs( 'Hourly watchdog declared the import run for task ' . (int) $mlsimport_active_lock['task_id'] . ' stalled and failed it.' . PHP_EOL, 'manual' );
496 // The run was terminated as hopeless — tell the SaaS once (#208).
497 mlsimport_alert_open(
498 'import_stalled:' . (int) $mlsimport_active_lock['task_id'],
499 'import_stalled',
500 array( 'task_id' => (int) $mlsimport_active_lock['task_id'] )
501 );
502 }
503 }
504
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 }
512
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 }
524
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.
529
530 // Claim the run lock now that we are committed to processing.
531 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
532
533 // Every operation after lock acquisition belongs to one committed cron
534 // run. Keep that complete run inside a try/finally boundary so a Throwable
535 // from task discovery, connection gating, or one Import Run cannot leave
536 // the site locked until the transient TTL expires (#303).
537 try {
538 // Heartbeat (#208): record that a cron import is now running, so the next
539 // cron entry can tell a clean finish from a process that died mid-loop.
540 mlsimport_cron_heartbeat_start();
541
542 // Record sync attempt in telemetry. The 'syncs' counter is no longer
543 // bumped here (#283): a run-level tick belongs to no single connection
544 // and would double-count against the per-pull tick that
545 // mlsimport_telemetry_record_sync_result() now records — one pull, one
546 // tick, attributed to the pull's own connection, so the global syncs sum
547 // stays equal to the sum of the per-connection buckets.
548 mlsimport_telemetry_set( 'last_sync_attempt', time() );
549
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');
673 }
674
675
676
677 /*
678 * Reconciliation Mechanism
679 *
680 *
681 *
682 **/
683
684 if ( ! wp_next_scheduled( 'mlsimport_reconciliation_event' ) ) {
685 wp_schedule_event( time(), 'daily', 'mlsimport_reconciliation_event' );
686 }
687
688 add_action( 'mlsimport_reconciliation_event', 'mlsimport_saas_reconciliation_event_function' );
689 add_action( 'mlsimport_reconciliation_retry_event', 'mlsimport_saas_reconciliation_event_function' );
690
691 if ( ! wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) {
692 wp_schedule_event( time(), 'daily', 'mlsimport_daily_telemetry_event' );
693 }
694
695 add_action( 'mlsimport_daily_telemetry_event', 'mlsimport_telemetry_run_daily' );
696
697
698 /*
699 * Force use of transient
700 *
701 *
702 *
703 **/
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 */
714 function mlsimport_force_use_transient( $value ) {
715 return $value;
716 // return false;
717 }
718
719
720
721
722 // Instantiate the core orchestrator and register all admin/public hooks.
723 global $mlsimport;
724 $mlsimport = new Mlsimport();
725 $mlsimport->run();
726
727
728
729
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.
733 $supported_theme = array(
734 990 => 'Standalone (any theme)',
735 991 => 'WpResidence',
736 992 => 'Houzez',
737 993 => 'Real Homes',
738 994 => 'Wpestate',
739
740 );
741
742 // Expose the theme map as a constant for use across the plugin.
743 define( 'MLSIMPORT_THEME', $supported_theme );
744
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 */
752 function mlsimport_saas_filter_timelimit( $time_limit ) {
753 return 3000;
754 }
755
756
757
758 /*
759 *
760 * Write logs
761 *
762 **/
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 */
771 function mlsimport_saas_single_write_import_custom_logs( $message, $tip_import = 'normal' ) {
772 // Check if logging is enabled
773 $enable_logs = intval( get_option( 'mlsimport_disable_logs' ) );
774 // Bail unless the "logs enabled" option equals exactly 1.
775 if ( 1 !== $enable_logs) {
776 return;
777 }
778
779 // Encode array payloads to JSON so they can be written as text.
780 if ( is_array( $message ) ) {
781 $message = wp_json_encode( $message );
782 }
783
784 // Prefix the message with a UTC timestamp.
785 $formatted_message = gmdate( 'F j, Y, g:i a' ) . ' -> ' . $message;
786
787 // Determine the log file path based on the import type
788 $log_file_name = 'cron' === $tip_import ? 'cron_logs' :
789 ( 'delete' === $tip_import ? 'delete_logs' :
790 ( 'server_cron' === $tip_import ? 'server_cron_logs' : 'import_logs' ) );
791
792 // Construct the full path with a date suffix
793 $log_file_path = WP_PLUGIN_DIR . "/mlsimport/logs/{$log_file_name}-" . gmdate( 'Y-m-d' ) . '.log';
794
795 // Error handling for file operations
796 try {
797 // Check and create the directory for logs if it does not exist
798 $log_dir = dirname( $log_file_path );
799 if ( ! file_exists( $log_dir ) ) {
800 mkdir( $log_dir, 0755, true );
801 }
802
803 // Append the formatted message to the log file
804 file_put_contents( $log_file_path, $formatted_message, FILE_APPEND | LOCK_EX );
805 } catch ( Exception $e ) {
806 // Handle the exception, such as logging the error elsewhere or sending a notification
807 }
808 }
809
810
811
812 /*
813 *
814 *
815 * Write Status logs
816 *
817 *
818 **/
819
820
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 */
831 function mlsimport_debuglogs_per_plugin_old( $message ) {
832
833 // Encode array payloads to JSON.
834 if ( is_array( $message ) ) {
835 $message = wp_json_encode( $message );
836 }
837
838 global $wp_filesystem;
839 if ( empty( $wp_filesystem ) ) {
840 require_once ABSPATH . '/wp-admin/includes/file.php';
841 WP_Filesystem();
842 }
843
844 $path_status = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
845 file_put_contents( $path_status, $message, LOCK_EX );
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 */
853 function mlsimport_debuglogs_per_plugin( $message ) {
854
855 // Encode array payloads to JSON.
856 if ( is_array( $message ) ) {
857 $message = wp_json_encode( $message );
858 }
859
860 // Nothing to write for an empty message.
861 if ( empty( $message ) ) {
862 return; // Exit the function if there's nothing to log
863 }
864
865 // Target log file inside the plugin's logs/ directory.
866 $log_file_path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
867
868 // Check and create the directory for logs if it does not exist
869 $log_dir = dirname( $log_file_path );
870 if ( ! file_exists( $log_dir ) ) {
871 mkdir( $log_dir, 0755, true );
872 }
873
874 // Error handling for file operations
875 try {
876 // Append the message to the log file with a newline and acquire an exclusive lock during writing
877 file_put_contents( $log_file_path, $message . PHP_EOL, LOCK_EX );
878 } catch ( Exception $e ) {
879 // Handle the exception, such as logging the error elsewhere or sending a notification
880 }
881 }
882
883
884
885
886
887 /*
888 * Cron job trigger
889 *
890 *
891 *
892 **/
893
894
895 // */5 * * * * wget http://example.com/check */2
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 */
905 function mlsimport_trigger_cron_job() {
906 // ?mlsimport_cron=yes
907 // Only proceed when the request carries mlsimport_cron=yes.
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).
910 $last_run = intval( get_option( 'mlsimport_last_server_cron' ) );
911 // Current time.
912 $now = time();
913 // First-ever call: seed the last-run timestamp.
914 if ( 0 === intval($last_run) ) {
915 update_option( 'mlsimport_last_server_cron', $now );
916 }
917
918 // Only run if at least 2 hours have elapsed since the last run.
919 if ( $last_run < $now - ( 60 * 60 * 2 ) ) {
920 // Build the "triggered" log line and record the new run time.
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;
922 // mlsimport_saas_event_mls_import_auto_function();
923 update_option( 'mlsimport_last_server_cron', $now );
924 } else {
925 $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;
926 }
927
928 mlsimport_saas_single_write_import_custom_logs( $log, 'server_cron' );
929 }
930 }
931
932
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 */
941 function mlsimport_show_signup() {
942 // Default sign-up URL.
943 $affiliate_url = 'https://mlsimport.com';
944 // Swap in the WpResidence affiliate/campaign link when that theme is active.
945 if ( function_exists( 'wp_estate_init' ) ) {
946 $affiliate_url = 'https://mlsimport.com/ref/1/?campaign=wpresidence';
947 }
948 // Output the promo markup.
949 ?>
950 <div class="mlsimport_signup">
951 <h3><?php esc_html_e('Import MLS Listings into your Real Estate website', 'mlsimport'); ?></h3>
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>
954 </div>
955 <?php
956 }
957
958
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 */
968 function force_recount_all_terms() {
969 global $wpdb;
970
971 // Get all taxonomies
972 $taxonomies = get_taxonomies([], 'names');
973
974 // Recount terms for each registered taxonomy.
975 foreach ($taxonomies as $taxonomy) {
976 // Get all terms for the taxonomy
977 $terms = get_terms([
978 'taxonomy' => $taxonomy,
979 'hide_empty' => false, // Include terms with 0 count
980 'fields' => 'ids', // Get only the term IDs
981 ]);
982
983 if (!is_wp_error($terms) && !empty($terms)) {
984 // Get term_taxonomy_ids for these terms
985 $term_taxonomy_ids = $wpdb->get_col($wpdb->prepare(
986 "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id IN (" . implode(',', array_map('intval', $terms)) . ")"
987 ));
988
989 // Update term counts
990 if (!empty($term_taxonomy_ids)) {
991 wp_update_term_count_now($term_taxonomy_ids, $taxonomy);
992 }
993 }
994 }
995
996 echo "Term counts have been recalculated for all taxonomies.";
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 }
1082