PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.3
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.3
7.2.3 7.2.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 All 38 releases
mlsimport / mlsimport.php

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

1,145 lines 54.7 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.3
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.3');
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 wp_clear_scheduled_hook( 'mlsimport_saved_search_daily' ); // Saved Search daily alerts (issue #221).
102 delete_option( 'mlsimport_reconciliation_running' );
103 Mlsimport_Deactivator::deactivate();
104 }
105
106
107
108 register_activation_hook( __FILE__, 'mlsimport_activate' );
109 register_deactivation_hook( __FILE__, 'mlsimport_deactivate' );
110
111 /**
112 * Show one-time modal about the switch from Delete Statuses to Protected Statuses.
113 */
114 add_action( 'admin_footer', 'mlsimport_protected_statuses_upgrade_modal' );
115 function mlsimport_protected_statuses_upgrade_modal() {
116 // Bail if the user already acknowledged/dismissed the notice.
117 if ( get_option( 'mlsimport_dismiss_protected_status_notice' ) ) {
118 return;
119 }
120 // Only show for sites that had a version before 6.2 (not fresh installs)
121 $show_modal = get_option( 'mlsimport_show_protected_status_modal' );
122 // Bail when the upgrade flag was never set for this site.
123 if ( ! $show_modal ) {
124 return;
125 }
126 // Nonce for the dismissal AJAX call embedded in the modal's inline script.
127 $nonce = wp_create_nonce( 'mlsimport_dismiss_protected_notice' );
128 // Link the user to their Import Tasks list to set Protected Statuses.
129 $import_tasks_url = admin_url( 'edit.php?post_type=mlsimport_item' );
130 // Emit the modal markup + inline dismissal script into the admin footer.
131 ?>
132 <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;">
133 <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);">
134 <h2 style="margin-top:0;color:#d63638;">MLSImport - Important Change</h2>
135 <p><strong>"Delete Statuses"</strong> have been removed. The plugin now uses <strong>Protected Statuses</strong> only.</p>
136 <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>
137 <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>
138 <button id="mlsimport-acknowledge-btn" class="button button-primary" style="margin-top:10px;font-size:14px;padding:6px 24px;">I acknowledge</button>
139 </div>
140 </div>
141 <script>
142 document.getElementById('mlsimport-acknowledge-btn').addEventListener('click', function() {
143 var btn = this;
144 btn.disabled = true;
145 btn.textContent = 'Saving...';
146 var xhr = new XMLHttpRequest();
147 xhr.open('POST', ajaxurl);
148 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
149 xhr.onload = function() {
150 document.getElementById('mlsimport-protected-status-modal').style.display = 'none';
151 };
152 xhr.send('action=mlsimport_dismiss_protected_notice&_wpnonce=<?php echo esc_js( $nonce ); ?>');
153 });
154 </script>
155 <?php
156 }
157 add_action( 'wp_ajax_mlsimport_dismiss_protected_notice', 'mlsimport_handle_dismiss_protected_notice' );
158 /**
159 * AJAX handler: persist the user's dismissal of the protected-statuses modal.
160 *
161 * Verifies the nonce, records the permanent "dismissed" flag, clears the
162 * "show modal" flag, and returns a success JSON response.
163 *
164 * @return void
165 */
166 function mlsimport_handle_dismiss_protected_notice() {
167 // Verify the nonce created in the modal markup above.
168 check_ajax_referer( 'mlsimport_dismiss_protected_notice' );
169 // A nonce proves where the request came from, not who sent it: only an
170 // administrator may switch the notice off for the whole site.
171 if ( ! current_user_can( 'manage_options' ) ) {
172 wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
173 }
174 // Persist that this notice has been acknowledged so it never shows again.
175 update_option( 'mlsimport_dismiss_protected_status_notice', true );
176 // Remove the one-time "show modal" flag.
177 delete_option( 'mlsimport_show_protected_status_modal' );
178 // Return an empty success payload to the inline script.
179 wp_send_json_success();
180 }
181
182 /**
183 * Track installed version and flag upgrade modals.
184 * Fresh installs get current version immediately, so no upgrade modal.
185 * Upgrades from < 6.2 flag the protected status modal.
186 */
187 $mlsimport_prev_version = get_option( 'mlsimport_installed_version', '' );
188 if ( $mlsimport_prev_version !== MLSIMPORT_VERSION ) {
189 if ( ! empty( $mlsimport_prev_version ) && version_compare( $mlsimport_prev_version, '6.2', '<' ) ) {
190 update_option( 'mlsimport_show_protected_status_modal', true );
191 }
192 update_option( 'mlsimport_installed_version', MLSIMPORT_VERSION );
193 }
194
195 /**
196 * The core plugin class that is used to define internationalization,
197 * admin-specific hooks, and public-facing site hooks.
198 */
199
200 /*
201 * Action Scheduler — the plugin's only third-party runtime dependency.
202 *
203 * This is deliberately a direct require rather than `vendor/autoload.php`.
204 * Composer's generated autoloader is written differently depending on whether
205 * dev dependencies (phpunit, php_codesniffer, myclabs/deep-copy) happen to be
206 * installed at the time it was generated. Those dev packages are never shipped,
207 * so a dev-generated autoloader committed to the repo makes `autoload_files.php`
208 * eagerly require files that do not exist in the released plugin — a fatal error
209 * on activation, before any plugin code runs.
210 *
211 * Action Scheduler is built to be dropped into a plugin and included directly
212 * (that is how WooCommerce loads it); it registers its own class loader and
213 * negotiates versions with any other copy already loaded on the site. So there
214 * is nothing left for the Composer autoloader to do at runtime, and not shipping
215 * it removes that whole failure mode. Composer is still used for dev tooling.
216 */
217 require_once plugin_dir_path( __FILE__ ) . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
218 // Core includes: RESO field defs + helpers, provider map, cron/reconciliation
219 // guards, status taxonomy/normalizer, then the orchestrator and API client.
220 require_once plugin_dir_path( __FILE__ ) . 'includes/help_functions.php';
221 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-theme-detection.php';
222 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-credentials.php';
223 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-provider-map.php';
224 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-enum-labels.php';
225 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-country.php';
226 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-reconciliation-guard.php';
227 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-cron-guard.php';
228 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-health.php';
229 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-status-taxonomy.php';
230 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-reconciliation.php';
231 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-reconciliation-wordpress-environment.php';
232 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-import-task-execution.php';
233 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-import-task-execution-wordpress-environment.php';
234 // The single "start a manual import" function: task screen, setup wizard, AI tool.
235 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-manual-import-start.php';
236 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-status-normalize.php';
237 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-fields.php';
238 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-title.php';
239 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-media.php';
240 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-listing-key-migration.php';
241 // Multi-MLS connection registry + the one-time single->multi MLS migration
242 // (the migration file hooks itself on init, after the listing-key migration).
243 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-connections.php';
244 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-multimls-migration.php';
245 // Per-connection option resolution + settings export/import transfer (#275):
246 // every read/write of the "_{mls_id}"-suffixed per-MLS state goes through these.
247 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connection-options.php';
248 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-settings-transfer.php';
249 // N-entitlement SaaS contract consumer (#276): entitlements parsing, the
250 // mls_id echo guard, and the stable not_entitled rejection handling.
251 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-entitlements.php';
252 // Friendly wording for MLS query errors the customer can fix on the Import
253 // Task (unsupported InternetEntireListingDisplayYN / InternetAddressDisplayYN).
254 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-mls-error-messages.php';
255 // Import task connection binding + cron isolation (#277): a task belongs to
256 // ONE connection for life; the hourly cron gates each task on ITS connection.
257 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-binding.php';
258 // The Tools tab actions as plain functions (Tools tab + AI tools).
259 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-admin-tools.php';
260 // The one save of an Import Task's settings (task editor + AI tool update-task).
261 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-task-settings.php';
262 // Per-connection reconciliation (#279): the daily event runs the untouched
263 // decision module once per connection, each with an echo-guarded scoped
264 // snapshot and an inventory limited to that connection's stamped listings.
265 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-reconciliation-connections.php';
266 // Connections screen (#280): the settings-page Connections tab — screen data
267 // (registry rows + activity + plan cap + account state) and its AJAX handlers
268 // (drag-reorder priority, per-row connection test, account disconnect).
269 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-screen.php';
270 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-ajax.php';
271 // Add-MLS drawer (#281): connection-scoped metadata gather (shared with the
272 // admin metadata AJAX) + the drawer's refuse/test/register/seed flow.
273 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-gather.php';
274 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-add.php';
275 // The follow-up field-mapping seed the drawer posts after a saved add (#325).
276 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-seed.php';
277 // Edit-connection drawer (tab consolidation): the retired "MLS Connection"
278 // credentials tab's replacement — refuse/test/store flow that updates a
279 // registered connection's credentials (and mirrors the current connection's
280 // into the flat options).
281 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-edit.php';
282 // Remove-connection flow: a row's Remove action — drops the record + its
283 // per-connection options and promotes the next-priority connection when the
284 // current one was removed. Imported listings and tasks stay.
285 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-connections-remove.php';
286 // Dedupe mechanics (#282): one visible copy per physical address across
287 // connections — winner by priority, loser hidden (never deleted), promoted
288 // back automatically when the winner disappears. Address normalization is
289 // the pure half; the evaluator/hooks half depends on it.
290 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-dedupe-address.php';
291 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-dedupe.php';
292 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-wordpress-environment.php';
293 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-write.php';
294 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-stored-listing-adapter-factory.php';
295 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport.php';
296 require_once plugin_dir_path( __FILE__ ) . 'includes/ThemeImport.php';
297 require_once plugin_dir_path( __FILE__ ) . 'enviroment/StandaloneClass.php';
298 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResidenceClass.php';
299 require_once plugin_dir_path( __FILE__ ) . 'enviroment/EstateClass.php';
300 require_once plugin_dir_path( __FILE__ ) . 'enviroment/HouzezClass.php';
301 require_once plugin_dir_path( __FILE__ ) . 'enviroment/RealHomesClass.php';
302 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ResoBase.php';
303 require_once plugin_dir_path( __FILE__ ) . 'enviroment/SparkResoClass.php';
304 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BridgeResoClass.php';
305 require_once plugin_dir_path( __FILE__ ) . 'enviroment/TresleResoClass.php';
306 require_once plugin_dir_path( __FILE__ ) . 'enviroment/MlsgridResoClass.php';
307 require_once plugin_dir_path( __FILE__ ) . 'enviroment/BrightMlsResoClass.php';
308 require_once plugin_dir_path( __FILE__ ) . 'enviroment/CentrisResoClass.php';
309 require_once plugin_dir_path( __FILE__ ) . 'enviroment/ProviderResoClasses.php';
310 require_once plugin_dir_path( __FILE__ ) . 'enviroment/UnsupportedResoClass.php';
311 require_once plugin_dir_path( __FILE__ ) . 'includes/addons/agents_offices.php';
312 // Why the last SaaS login failed (no subscription vs bad password) and the
313 // one "not connected" box every screen prints.
314 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-account-status.php';
315 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-onboarding.php';
316 // AI assistant access (MCP). Only this bootstrap loads on every request; it
317 // loads the rest of includes/mcp/ and the bundled adapter when the Tools tab
318 // switch is on and WordPress is 6.9 or newer.
319 require_once plugin_dir_path( __FILE__ ) . 'includes/mcp/mlsimport-mcp-bootstrap.php';
320
321 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-field-configuration.php';
322 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-selector-functions.php';
323 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-progressive-save.php';
324 // Per-connection field-mapping UI: the one scope-resolution rule shared by the
325 // field_options tab, the field-configuration AJAX, and the metadata-gather AJAX.
326 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-field-mapping-scope.php';
327 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-metadata-autotrigger.php';
328 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-telemetry.php';
329 // #283: per-connection telemetry (sync stamps + the connections payload seam).
330 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-telemetry-connections.php';
331 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-activity-log.php';
332 // The one Import History query (wp-admin table + AI tool import-history).
333 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-activity-query.php';
334 // #222: Listing Change Events — pure diff at the write boundary + the events
335 // table, hooks, retention prune. Read in wp-admin (Listing Changes page) only.
336 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-listing-events-diff.php';
337 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-listing-events.php';
338 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-listing-event-facts.php';
339 // Listing change email notifications: kind + filter matcher, saved settings.
340 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-listing-notification-matcher.php';
341 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-listing-notification-settings.php';
342 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-buyer-searches.php';
343 require_once plugin_dir_path( __FILE__ ) . 'includes/class-mlsimport-listing-notification-digest.php';
344 // #208: internal incident alerts (dedup + resolve) and import/connection health watch.
345 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-alerts.php';
346 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-import-health.php';
347 // A page's own CSS (post meta), printed in that page's head; written by the AI tools.
348 require_once plugin_dir_path( __FILE__ ) . 'includes/mlsimport-custom-css.php';
349
350 /*
351 * Standalone (theme_id 990) mode — own listings table, CPTs and taxonomies.
352 * Registered on every load (ADR-0003); the table is created on activation and
353 * upgraded behind a version guard on admin_init.
354 */
355 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/mlsimport-hooks.php'; // Hook reference + convention (loaded early).
356 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-table.php';
357 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-cpt.php';
358 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-term-select.php';
359 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-shortcodes.php';
360 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-block.php';
361 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-ajax.php';
362 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-reindex.php';
363 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-assets.php';
364 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-standalone-single.php';
365 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-section-registry.php';
366 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-print.php';
367 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-shortcodes.php';
368 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-blocks.php';
369 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-section-elementor.php';
370 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-lead.php';
371 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/agent-sections.php';
372 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/property-schema.php';
373 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-metabox.php';
374 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-agent-metabox.php';
375 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-term-meta.php';
376 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-columns.php';
377 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-property-featured-admin.php'; // "Featured" bulk actions + list column on the Properties screen.
378 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-favorites.php';
379 // Saved Search + daily email alerts (Standalone mode only, ADR-0018).
380 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-saved-search.php';
381 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-saved-search-mailer.php';
382 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-saved-search-alerts.php';
383 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-saved-search-front.php';
384 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-saved-search-admin.php';
385 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/page-block-registry.php';
386 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/page-block-styles.php'; // Per-block look of the listing-card blocks (block editor `styles` attribute + scoped CSS).
387 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-shortcodes.php';
388 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-blocks.php';
389 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-page-block-elementor.php';
390 require_once plugin_dir_path( __FILE__ ) . 'includes/standalone/class-mlsimport-customizer.php'; // Standalone design settings in the WP Customizer (990 mode).
391 require_once plugin_dir_path( __FILE__ ) . 'includes/live/live-bootstrap.php'; // Live MLS passthrough mode (seam #1 — its only existing-file require).
392 add_action( 'init', array( 'Mlsimport_Standalone_Cpt', 'register' ) );
393 // After register(): drop cached rewrite rules when the standalone-mode signature changed (#206).
394 add_action( 'init', array( 'Mlsimport_Standalone_Cpt', 'maybe_flush_rewrites' ), 20 );
395 add_action( 'init', array( 'Mlsimport_Property_Metabox', 'register' ) );
396 add_action( 'init', array( 'Mlsimport_Agent_Metabox', 'register' ) );
397 add_action( 'init', array( 'Mlsimport_Term_Meta', 'register' ) );
398 add_action( 'init', array( 'Mlsimport_Property_Columns', 'register' ) );
399 add_action( 'init', array( 'Mlsimport_Property_Featured_Admin', 'register' ) );
400 add_action( 'init', array( 'Mlsimport_Standalone_Shortcodes', 'register' ) );
401 add_action( 'init', array( 'Mlsimport_Standalone_Block', 'register' ) );
402 add_action( 'init', array( 'Mlsimport_Standalone_Ajax', 'register' ) );
403 add_action( 'init', array( 'Mlsimport_Favorites', 'register' ) );
404 add_action( 'init', array( 'Mlsimport_Saved_Search', 'register' ) );
405 add_action( 'init', array( 'Mlsimport_Saved_Search_Alerts', 'register' ) );
406 add_action( 'init', array( 'Mlsimport_Saved_Search_Front', 'register' ) );
407 add_action( 'init', array( 'Mlsimport_Saved_Search_Admin', 'register' ) );
408 add_action( 'init', array( 'Mlsimport_Property_Section_Shortcodes', 'register' ) );
409 // Property-section Gutenberg blocks (mlsimport/property-*, "MLSImport — Property"
410 // category) are intentionally NOT registered — we don't expose them as blocks.
411 // The render dispatcher, shortcodes and Elementor widget behind them stay active.
412 // add_action( 'init', array( 'Mlsimport_Property_Section_Blocks', 'register' ) );
413 add_action( 'init', array( 'Mlsimport_Page_Block_Shortcodes', 'register' ) );
414 add_action( 'init', array( 'Mlsimport_Page_Block_Blocks', 'register' ) );
415 // Two MLSImport categories in the block inserter: single-property section blocks
416 // ("Property") and the page-builder blocks ("Real Estate").
417 add_filter(
418 'block_categories_all',
419 static function ( $categories ) {
420 return array_merge(
421 array(
422 array( 'slug' => 'mlsimport-property', 'title' => __( 'MLSImport — Property', 'mlsimport' ) ),
423 array( 'slug' => 'mlsimport-real-estate', 'title' => __( 'MLSImport — Real Estate', 'mlsimport' ) ),
424 ),
425 $categories
426 );
427 }
428 );
429 add_action( 'init', array( 'Mlsimport_Property_Lead', 'register' ) );
430 // Priority 20 (not the default 10) so the plugin's self-contained BEM stylesheets
431 // enqueue AFTER the active theme's own styles. Themes hook wp_enqueue_scripts at 10
432 // and, because plugins load before the theme, our default-10 callback would print
433 // FIRST — losing every equal-specificity tie to a theme rule that prints later.
434 // Hello Elementor's reset.css is the concrete case: it styles bare `button` /
435 // `[type=button]` (pink #c36, inline-block, width:auto), which ties our single-class
436 // `.mlsimport-*` button rules (0,1,0) and won on order, so the multiselect control,
437 // range/beds toggles, popup buttons, submit and the card heart all rendered narrow
438 // and pink. Printing after the theme lets our base rules win that tie. It stays a
439 // SINGLE class (0,1,0), so component state rules (:focus / :hover / .is-open at
440 // 0,1,1+) and any Elementor Style-tab control ({{WRAPPER}} … at 0,2,0+) still win —
441 // this only reclaims the tie against a generic theme reset, and never matches a
442 // non-plugin (e.g. Elementor) element.
443 add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Standalone_Assets', 'enqueue' ), 20 );
444 add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Property_Section_Assets', 'ensure_registered' ) );
445 // Pre-enqueue the single-property section assets into the <head>; the single
446 // template prints the header before any section renders, so the on-demand
447 // enqueue at render time lands in the footer and flashes unstyled (issue #172).
448 // Priority 20 for the same after-the-theme reason as the standalone assets above.
449 add_action( 'wp_enqueue_scripts', array( 'Mlsimport_Property_Section_Assets', 'enqueue_for_single' ), 20 );
450 // Same for the single-agent page (issue #188).
451 add_action( 'wp_enqueue_scripts', 'mlsimport_agent_enqueue_for_single', 20 );
452 // Load the same front-end CSS into the block editor so the dynamic blocks'
453 // ServerSideRender previews match the front end (editor-guarded inside the method).
454 add_action( 'enqueue_block_assets', array( 'Mlsimport_Standalone_Assets', 'enqueue_editor' ) );
455 add_filter( 'template_include', array( 'Mlsimport_Standalone_Single', 'template_include' ) );
456 add_action( 'wp_head', 'mlsimport_property_print_schema' );
457 Mlsimport_Property_Section_Elementor::register();
458 Mlsimport_Page_Block_Elementor::register();
459 // A contact-form lead carries no property and no agent; route those general
460 // enquiries to the "Contact form recipients" setting (decision 3).
461 add_filter(
462 'mlsimport_property_lead_recipient',
463 static function ( $to, $property_id, $agent_id = 0 ) {
464 if ( $property_id || $agent_id ) {
465 return $to;
466 }
467 $recipients = trim( (string) mlsimport_standalone_option( 'contact_form_recipients', '' ) );
468 return '' !== $recipients ? $recipients : $to;
469 },
470 10,
471 3
472 );
473 add_action( 'admin_init', array( 'Mlsimport_Standalone_Table', 'maybe_upgrade' ) );
474 // Also on the front end: after an auto-update or WP-CLI update nobody may open
475 // wp-admin, and the listings query would sort on a column that does not exist yet.
476 // One autoloaded option read per request when the schema is already current.
477 add_action( 'init', array( 'Mlsimport_Standalone_Table', 'maybe_upgrade' ), 1 );
478 // One-time repair of comma-glued taxonomy terms written before 7.1.2 (#290).
479 add_action( 'admin_init', array( 'Mlsimport_Standalone_Cpt', 'maybe_split_packed_terms' ) );
480 add_action( 'before_delete_post', array( 'StandaloneClass', 'cleanup_on_delete' ) );
481 // Trash/unpublish (not a permanent delete) must also drop the listings row, so the
482 // search index only ever holds published listings.
483 add_action( 'transition_post_status', array( 'StandaloneClass', 'cleanup_on_status_change' ), 10, 3 );
484
485 if ( defined( 'WP_CLI' ) && WP_CLI ) {
486 WP_CLI::add_command(
487 'mlsimport reindex',
488 function () {
489 $rebuilt = Mlsimport_Standalone_Reindex::rebuild_all();
490 WP_CLI::success( "Reindexed {$rebuilt} standalone listings." );
491 }
492 );
493 }
494
495 if ( ! wp_next_scheduled( 'event_mls_import_auto' ) ) {
496 wp_schedule_event( time(), 'hourly', 'event_mls_import_auto' );
497 }
498
499
500 /**
501 * Scheduled event: Processes MLSimport items marked for cron processing, in memory-safe batches.
502 *
503 * This function is triggered by the 'event_mls_import_auto' action.
504 * It fetches mlsimport_item post IDs in small batches (not all at once!) to minimize memory usage.
505 * Only posts with meta 'mlsimport_item_stat_cron' = 1 are processed.
506 * For each item, calls mlsimport_saas_start_cron_links_per_item().
507 *
508 * Optimizations:
509 * - Uses 'fields' => 'ids' so only post IDs are loaded (saves memory)
510 * - Batches with posts_per_page/paged, so memory does not spike for large data sets
511 * - Calls gc_collect_cycles() periodically to further reduce memory leaks
512 * - Bails only when the SaaS token is missing (global, one account); each
513 * task is then gated on its OWN connection (#277) — a broken or deleted
514 * connection skips its tasks while healthy connections keep importing
515 *
516 * @return void
517 */
518 add_action('event_mls_import_auto', 'mlsimport_saas_event_mls_import_auto_function');
519 /**
520 * Scheduled event handler for MLS Import Auto (runs via WP Cron).
521 * Processes mlsimport_item posts in batches and logs memory usage.
522 */
523 function mlsimport_saas_event_mls_import_auto_function() {
524 global $mlsimport;
525
526 //error_log('[AutoCron] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
527
528 // Watchdog backstop (issue #199): a chunked manual import whose worker
529 // chain died is normally revived by the polled progress screen, but if
530 // the administrator closed that screen nothing else watches the run.
531 // Hourly cron picks it up here; revive() is a cheap no-op for anything
532 // that is not a silent, stalled manual run.
533 // Import-health checks (#208) run before anything can bail out below:
534 // detect a previous cron run that died mid-loop, and a manual run stuck
535 // at "Preparing". Both open one deduplicated internal incident.
536 mlsimport_cron_heartbeat_check();
537 mlsimport_import_health_watch_manual_run();
538
539 $mlsimport_active_lock = get_option( 'mlsimport_import_run_lock', array() );
540 if ( is_array( $mlsimport_active_lock ) && ! empty( $mlsimport_active_lock['task_id'] ) ) {
541 $mlsimport_revive = $mlsimport->admin->mlsimport_import_task_execution()->revive( (int) $mlsimport_active_lock['task_id'] );
542 if ( true === ( $mlsimport_revive['revived'] ?? false ) ) {
543 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' );
544 } elseif ( 'stalled' === ( $mlsimport_revive['reason'] ?? '' ) ) {
545 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' );
546 // The run was terminated as hopeless — tell the SaaS once (#208).
547 mlsimport_alert_open(
548 'import_stalled:' . (int) $mlsimport_active_lock['task_id'],
549 'import_stalled',
550 array( 'task_id' => (int) $mlsimport_active_lock['task_id'] )
551 );
552 }
553 }
554
555 // 0. Bail if a run is already in progress. Without this guard an overlapping
556 // cron fire processes the same listings in parallel, causing duplicate
557 // listing_key inserts and term_relationship/term_count deadlocks. The TTL is
558 // the safety net if a run dies mid-loop without reaching the release below.
559 if ( get_transient( 'mlsimport_cron_running' ) ) {
560 return;
561 }
562
563 // 1. Get the API token from transient - exit if not set
564 $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
565 //error_log('[AutoCron] After token fetch: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
566 if (trim($token) === '') {
567 // Silent exit made frozen sites undiagnosable (issue #207 finding 3):
568 // record the failed attempt with a real class before bailing.
569 mlsimport_telemetry_set( 'last_sync_failed', time() );
570 mlsimport_telemetry_set( 'last_sync_failed_code', 'no_token' );
571 //error_log('[AutoCron] No token, exiting.');
572 return;
573 }
574
575 // 2. The connection-status check is no longer a global bail-out (#277):
576 // each task is gated on ITS OWN connection inside the loop below, so one
577 // broken MLS never blocks tasks on healthy connections. Only the SaaS
578 // token above stays global — one account, one token.
579
580 // Claim the run lock now that we are committed to processing.
581 set_transient( 'mlsimport_cron_running', 1, 15 * MINUTE_IN_SECONDS );
582
583 // Every operation after lock acquisition belongs to one committed cron
584 // run. Keep that complete run inside a try/finally boundary so a Throwable
585 // from task discovery, connection gating, or one Import Run cannot leave
586 // the site locked until the transient TTL expires (#303).
587 try {
588 // Heartbeat (#208): record that a cron import is now running, so the next
589 // cron entry can tell a clean finish from a process that died mid-loop.
590 mlsimport_cron_heartbeat_start();
591
592 // Record sync attempt in telemetry. The 'syncs' counter is no longer
593 // bumped here (#283): a run-level tick belongs to no single connection
594 // and would double-count against the per-pull tick that
595 // mlsimport_telemetry_record_sync_result() now records — one pull, one
596 // tick, attributed to the pull's own connection, so the global syncs sum
597 // stays equal to the sum of the per-connection buckets.
598 mlsimport_telemetry_set( 'last_sync_attempt', time() );
599
600 // 3. Initialize the loop counter.
601 $total_processed = 0;
602
603 // 4. Gather every cron-enabled task id first (ids only — a few bytes each,
604 // still fetched in paged batches so the query never loads post objects).
605 // Gathering before processing is what makes fair ordering possible below.
606 $cron_task_ids = array();
607 do {
608 // Prepare query: only IDs, filter by meta key, batch, paged, no_found_rows speeds up query
609 $args = array(
610 'post_type' => 'mlsimport_item',
611 'post_status' => 'any',
612 'posts_per_page' => $batch_size,
613 'paged' => $paged,
614 'fields' => 'ids',
615 'meta_query' => array(
616 array(
617 'key' => 'mlsimport_item_stat_cron',
618 'value' => 1,
619 'compare' => '=',
620 ),
621 ),
622 'no_found_rows' => true,
623 );
624
625 // Get post IDs for this batch
626 $post_ids = get_posts($args);
627
628 // If nothing is returned, the gather is complete
629 if (empty($post_ids)) {
630 break;
631 }
632
633 foreach ($post_ids as $prop_id) {
634 $cron_task_ids[] = (int) $prop_id;
635 }
636
637 // Prepare next batch
638 $paged++;
639 unset($post_ids); // Free memory
640
641 } while (true);
642
643 // 5. Order by starvation (issue #203): the query above returns tasks in
644 // the same fixed order every hour, so when an early large task ate the
645 // whole cycle the bottom tasks were skipped run after run. The queue key
646 // is the newer of last success and last ATTEMPT (issue #330): ordering
647 // by success alone let one task whose run never completes keep the
648 // oldest stamp and hold first place every hour while the rest starved.
649 $cron_task_watermarks = array();
650 foreach ($cron_task_ids as $prop_id) {
651 $cron_task_watermarks[ $prop_id ] = mlsimport_cron_task_queue_key(
652 (string) get_post_meta( $prop_id, 'mlsimport_last_date', true ),
653 (string) get_post_meta( $prop_id, 'mlsimport_last_attempt', true )
654 );
655 }
656 unset($cron_task_ids);
657
658 // 6. Process every task, most starved first. A broken connection is
659 // recorded ONCE per run, not once per task — the entry is identical and
660 // re-writing telemetry for every skipped task would only amplify writes.
661 $failed_connections = array();
662 foreach (mlsimport_cron_task_order($cron_task_watermarks) as $prop_id) {
663 $logs = 'Loop custom post: ' . $prop_id . PHP_EOL;
664 mlsimport_debuglogs_per_plugin($logs);
665
666 // Per-connection failure isolation (#277): gate this task on ITS OWN
667 // connection. A broken connection's tasks skip with a recorded reason;
668 // a DELETED connection's tasks skip with a deduplicated health
669 // incident and are never re-defaulted; healthy connections keep
670 // importing in this same run.
671 $mlsimport_gate = mlsimport_cron_task_gate( (int) $prop_id );
672 if ( 'import' !== $mlsimport_gate['action'] ) {
673 if ( 'skip_missing' === $mlsimport_gate['action'] ) {
674 mlsimport_alert_open(
675 'task_connection_missing:' . (int) $prop_id,
676 'task_connection_missing',
677 array( 'task_id' => (int) $prop_id, 'mls_id' => $mlsimport_gate['mls_id'] )
678 );
679 } elseif ( ! isset( $failed_connections[ $mlsimport_gate['mls_id'] ] ) ) {
680 $failed_connections[ $mlsimport_gate['mls_id'] ] = true;
681 mlsimport_record_connection_sync_failure( $mlsimport_gate['mls_id'], $mlsimport_gate['code'] );
682 }
683 mlsimport_debuglogs_per_plugin(
684 'Task ' . $prop_id . ' skipped: connection ' . $mlsimport_gate['mls_id']
685 . ' ' . $mlsimport_gate['action'] . ' (' . $mlsimport_gate['code'] . ')' . PHP_EOL
686 );
687 continue;
688 }
689
690 // Call processing function for this item. The feed count it pulls
691 // is recorded inside mlsimport_make_listing_requests() (last_feed_found).
692 $mlsimport->admin->mlsimport_saas_start_cron_links_per_item($prop_id);
693
694 $total_processed++;
695
696 // Heartbeat (#208): measurable progress for the stuck-run check.
697 mlsimport_cron_heartbeat_progress( $total_processed );
698
699 // Free memory every 100 processed items
700 if ($total_processed % 100 === 0) {
701 gc_collect_cycles();
702 //error_log("[AutoCron] Processed {$total_processed} total, memory: " . (memory_get_usage(true) / 1024 / 1024) . ' MB');
703 }
704 }
705
706 // Heartbeat (#208): clean finish — also resolves an open died-run incident.
707 } finally {
708 // A failed task still ends this cron invocation. Mark the heartbeat as
709 // finished and release the overlap lock while PHP continues propagating
710 // the original Throwable to the caller for logging and diagnosis.
711 mlsimport_cron_heartbeat_finish();
712 delete_transient( 'mlsimport_cron_running' );
713 }
714
715 // last_sync_success is no longer stamped here (issue #207 finding 1): the
716 // end-of-loop stamp reported success even when every request failed, and
717 // never fired when a run died mid-loop. Each listings request now records
718 // its own outcome inside mlsimport_make_listing_requests().
719
720 //error_log('[AutoCron] Done, total processed: ' . $total_processed . ', end memory: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB');
721 }
722
723
724
725 /*
726 * Reconciliation Mechanism
727 *
728 *
729 *
730 **/
731
732 if ( ! wp_next_scheduled( 'mlsimport_reconciliation_event' ) ) {
733 wp_schedule_event( time(), 'daily', 'mlsimport_reconciliation_event' );
734 }
735
736 add_action( 'mlsimport_reconciliation_event', 'mlsimport_saas_reconciliation_event_function' );
737 add_action( 'mlsimport_reconciliation_retry_event', 'mlsimport_saas_reconciliation_event_function' );
738
739 if ( ! wp_next_scheduled( 'mlsimport_daily_telemetry_event' ) ) {
740 wp_schedule_event( time(), 'daily', 'mlsimport_daily_telemetry_event' );
741 }
742
743 add_action( 'mlsimport_daily_telemetry_event', 'mlsimport_telemetry_run_daily' );
744
745
746 /*
747 * Force use of transient
748 *
749 *
750 *
751 **/
752
753 /**
754 * Filter callback stub that returns the transient value unchanged.
755 *
756 * Left as a pass-through hook point; the commented `return false;` would force
757 * a transient miss for debugging.
758 *
759 * @param mixed $value Incoming transient value.
760 * @return mixed The value unchanged.
761 */
762 function mlsimport_force_use_transient( $value ) {
763 return $value;
764 // return false;
765 }
766
767
768
769
770 // Instantiate the core orchestrator and register all admin/public hooks.
771 global $mlsimport;
772 $mlsimport = new Mlsimport();
773 $mlsimport->run();
774
775
776
777
778
779 // Map of supported theme IDs to human labels. 990 = standalone (no host theme
780 // dependency); 991-994 are the four supported real-estate themes.
781 $supported_theme = array(
782 990 => 'Standalone (any theme)',
783 991 => 'WpResidence',
784 992 => 'Houzez',
785 993 => 'Real Homes',
786 994 => 'Wpestate',
787
788 );
789
790 // Expose the theme map as a constant for use across the plugin.
791 define( 'MLSIMPORT_THEME', $supported_theme );
792
793 add_filter( 'action_scheduler_failure_period', 'mlsimport_saas_filter_timelimit' );
794 /**
795 * Raise the Action Scheduler failure timeout so long imports are not marked failed.
796 *
797 * @param int $time_limit Default failure period in seconds (unused).
798 * @return int Fixed failure period of 3000 seconds.
799 */
800 function mlsimport_saas_filter_timelimit( $time_limit ) {
801 return 3000;
802 }
803
804
805
806 /*
807 *
808 * Write logs
809 *
810 **/
811
812 /**
813 * The path of one daily log file.
814 *
815 * The one place that knows where a log bucket is stored, shared by the log
816 * writer below and the read-log AI tool (includes/mcp/mlsimport-mcp-history.php).
817 * Each bucket has one file per day (UTC).
818 *
819 * @param string $tip_import Log bucket: normal|cron|delete|server_cron.
820 * @param string $date The day as 'Y-m-d'.
821 * @return string Full path of that day's log file.
822 */
823 function mlsimport_log_file_path( string $tip_import, string $date ): string {
824 // Bucket to file name; anything unknown is the import log.
825 $log_file_name = 'cron' === $tip_import ? 'cron_logs' :
826 ( 'delete' === $tip_import ? 'delete_logs' :
827 ( 'server_cron' === $tip_import ? 'server_cron_logs' : 'import_logs' ) );
828
829 return WP_PLUGIN_DIR . "/mlsimport/logs/{$log_file_name}-" . $date . '.log';
830 }
831
832 /**
833 * Append a timestamped line to a per-type import log file (when logging is on).
834 *
835 * @param string|array $message Message to log; arrays are JSON-encoded.
836 * @param string $tip_import Log bucket: normal|cron|delete|server_cron.
837 * @return void
838 */
839 function mlsimport_saas_single_write_import_custom_logs( $message, $tip_import = 'normal' ) {
840 // Check if logging is enabled
841 $enable_logs = intval( get_option( 'mlsimport_disable_logs' ) );
842 // Bail unless the "logs enabled" option equals exactly 1.
843 if ( 1 !== $enable_logs) {
844 return;
845 }
846
847 // Encode array payloads to JSON so they can be written as text.
848 if ( is_array( $message ) ) {
849 $message = wp_json_encode( $message );
850 }
851
852 // Prefix the message with a UTC timestamp.
853 $formatted_message = gmdate( 'F j, Y, g:i a' ) . ' -> ' . $message;
854
855 // The day's log file for this bucket.
856 $log_file_path = mlsimport_log_file_path( (string) $tip_import, gmdate( 'Y-m-d' ) );
857
858 // Error handling for file operations
859 try {
860 // Check and create the directory for logs if it does not exist
861 $log_dir = dirname( $log_file_path );
862 if ( ! file_exists( $log_dir ) ) {
863 mkdir( $log_dir, 0755, true );
864 }
865
866 // Append the formatted message to the log file
867 file_put_contents( $log_file_path, $formatted_message, FILE_APPEND | LOCK_EX );
868 } catch ( Exception $e ) {
869 // Handle the exception, such as logging the error elsewhere or sending a notification
870 }
871 }
872
873
874
875 /*
876 *
877 *
878 * Write Status logs
879 *
880 *
881 **/
882
883
884
885 /**
886 * Legacy status-log writer (superseded by mlsimport_debuglogs_per_plugin()).
887 *
888 * Note: writes with LOCK_EX but no FILE_APPEND, so it overwrites status_logs.log
889 * on each call. Retained under the _old suffix; not called anywhere.
890 *
891 * @param string|array $message Message to log; arrays are JSON-encoded.
892 * @return void
893 */
894 function mlsimport_debuglogs_per_plugin_old( $message ) {
895
896 // Encode array payloads to JSON.
897 if ( is_array( $message ) ) {
898 $message = wp_json_encode( $message );
899 }
900
901 global $wp_filesystem;
902 if ( empty( $wp_filesystem ) ) {
903 require_once ABSPATH . '/wp-admin/includes/file.php';
904 WP_Filesystem();
905 }
906
907 $path_status = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
908 file_put_contents( $path_status, $message, LOCK_EX );
909 }
910 /**
911 * Append a status/debug line to logs/status_logs.log.
912 *
913 * @param string|array $message Message to log; arrays are JSON-encoded.
914 * @return void
915 */
916 function mlsimport_debuglogs_per_plugin( $message ) {
917
918 // Encode array payloads to JSON.
919 if ( is_array( $message ) ) {
920 $message = wp_json_encode( $message );
921 }
922
923 // Nothing to write for an empty message.
924 if ( empty( $message ) ) {
925 return; // Exit the function if there's nothing to log
926 }
927
928 // Target log file inside the plugin's logs/ directory.
929 $log_file_path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
930
931 // Check and create the directory for logs if it does not exist
932 $log_dir = dirname( $log_file_path );
933 if ( ! file_exists( $log_dir ) ) {
934 mkdir( $log_dir, 0755, true );
935 }
936
937 // Error handling for file operations
938 try {
939 // Append the message to the log file with a newline and acquire an exclusive lock during writing
940 file_put_contents( $log_file_path, $message . PHP_EOL, LOCK_EX );
941 } catch ( Exception $e ) {
942 // Handle the exception, such as logging the error elsewhere or sending a notification
943 }
944 }
945
946
947
948
949
950 /*
951 * Cron job trigger
952 *
953 *
954 *
955 **/
956
957
958 // */5 * * * * wget http://example.com/check */2
959 add_action( 'init', 'mlsimport_trigger_cron_job' );
960 /**
961 * Server-cron entry point hit via the ?mlsimport_cron=yes query parameter.
962 *
963 * Rate-limited to once every 2 hours. Note: the actual import call on the
964 * throttled branch is currently commented out, so this only writes a log line.
965 *
966 * @return void
967 */
968 function mlsimport_trigger_cron_job() {
969 // ?mlsimport_cron=yes
970 // Only proceed when the request carries mlsimport_cron=yes.
971 if ( isset( $_REQUEST['mlsimport_cron'] ) && 'yes' === sanitize_text_field( wp_unslash( $_REQUEST['mlsimport_cron'] ) ) ) {
972 // Timestamp of the previous server-cron run (0 if never run).
973 $last_run = intval( get_option( 'mlsimport_last_server_cron' ) );
974 // Current time.
975 $now = time();
976 // First-ever call: seed the last-run timestamp.
977 if ( 0 === intval($last_run) ) {
978 update_option( 'mlsimport_last_server_cron', $now );
979 }
980
981 // Only run if at least 2 hours have elapsed since the last run.
982 if ( $last_run < $now - ( 60 * 60 * 2 ) ) {
983 // Build the "triggered" log line and record the new run time.
984 $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;
985 // mlsimport_saas_event_mls_import_auto_function();
986 update_option( 'mlsimport_last_server_cron', $now );
987 } else {
988 $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;
989 }
990
991 mlsimport_saas_single_write_import_custom_logs( $log, 'server_cron' );
992 }
993 }
994
995
996
997 /**
998 * Render the "Sign up for MLSImport" promo box (30-day free-trial CTA).
999 *
1000 * Uses a WpResidence-specific affiliate URL when that theme is active.
1001 *
1002 * @return void
1003 */
1004 function mlsimport_show_signup() {
1005 // Default sign-up URL.
1006 $affiliate_url = 'https://mlsimport.com';
1007 // Swap in the WpResidence affiliate/campaign link when that theme is active.
1008 if ( function_exists( 'wp_estate_init' ) ) {
1009 $affiliate_url = 'https://mlsimport.com/ref/1/?campaign=wpresidence';
1010 }
1011 // Output the promo markup.
1012 ?>
1013 <div class="mlsimport_signup">
1014 <h3><?php esc_html_e('Import MLS Listings into your Real Estate website', 'mlsimport'); ?></h3>
1015 <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>
1016 <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>
1017 </div>
1018 <?php
1019 }
1020
1021
1022 //add_action('admin_init', 'force_recount_all_terms');
1023 /**
1024 * Maintenance utility: recalculate term counts for every taxonomy.
1025 *
1026 * Not hooked by default (the add_action above is commented out); intended to be
1027 * run manually to fix drifted term counts. Echoes a completion message.
1028 *
1029 * @return void
1030 */
1031 function force_recount_all_terms() {
1032 global $wpdb;
1033
1034 // Get all taxonomies
1035 $taxonomies = get_taxonomies([], 'names');
1036
1037 // Recount terms for each registered taxonomy.
1038 foreach ($taxonomies as $taxonomy) {
1039 // Get all terms for the taxonomy
1040 $terms = get_terms([
1041 'taxonomy' => $taxonomy,
1042 'hide_empty' => false, // Include terms with 0 count
1043 'fields' => 'ids', // Get only the term IDs
1044 ]);
1045
1046 if (!is_wp_error($terms) && !empty($terms)) {
1047 // Get term_taxonomy_ids for these terms
1048 $term_taxonomy_ids = $wpdb->get_col($wpdb->prepare(
1049 "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id IN (" . implode(',', array_map('intval', $terms)) . ")"
1050 ));
1051
1052 // Update term counts
1053 if (!empty($term_taxonomy_ids)) {
1054 wp_update_term_count_now($term_taxonomy_ids, $taxonomy);
1055 }
1056 }
1057 }
1058
1059 echo "Term counts have been recalculated for all taxonomies.";
1060 }
1061
1062
1063 // The theme <select> builder lives in includes/mlsimport-theme-detection.php,
1064 // next to mlsimport_resolve_theme_id() whose answer it renders (#242).
1065
1066
1067
1068 // mlsimport_save_account_callback() (wp_ajax_mlsimport_save_account) lives in
1069 // includes/mlsimport-onboarding.php with the wizard code that submits it. This
1070 // also keeps the live credential-save path loadable by the focused credential
1071 // sanitization and token-purge regression harnesses.
1072
1073
1074
1075
1076
1077
1078 add_action('wp_ajax_mlsimport_save_mls_data', 'mlsimport_save_mls_data_callback');
1079 function mlsimport_save_mls_data_callback() {
1080 check_ajax_referer('mlsimport_onboarding_nonce', 'security');
1081 if ( ! current_user_can( 'manage_options' ) ) {
1082 wp_send_json_error( array( 'message' => 'Unauthorized' ), 403 );
1083 }
1084
1085 $options = get_option('mlsimport_admin_options', []);
1086 $options = is_array( $options ) ? $options : array();
1087 $previous_mls_id = isset( $options['mlsimport_mls_name'] )
1088 ? (string) $options['mlsimport_mls_name']
1089 : '';
1090
1091 foreach ($_POST as $key => $value) {
1092 if (strpos($key, 'mlsimport_') === 0 && $key !== 'mlsimport_username' && $key !== 'mlsimport_password') {
1093 $options[$key] = sanitize_text_field($value);
1094 }
1095 }
1096
1097 // An MLS change invalidates only state owned by the old selection. Provider
1098 // credentials remain saved so returning to that provider restores its fields.
1099 $new_mls_id = isset( $options['mlsimport_mls_name'] )
1100 ? (string) $options['mlsimport_mls_name']
1101 : '';
1102 if ( $previous_mls_id !== $new_mls_id ) {
1103 Mlsimport_Provider_Family::clear_active_state();
1104 } else {
1105 Mlsimport_Provider_Family::clear_access_tokens();
1106 delete_option( 'mlsimport_connection_test' );
1107 }
1108
1109 // Saving credentials always forces a fresh metadata gather for the SAVED
1110 // MLS. The populated flag is per-connection (#275): only this connection's
1111 // flag is cleared — other connections' gathered state stays isolated.
1112 mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
1113
1114 update_option('mlsimport_admin_options', $options);
1115
1116 // Always test the newly saved selection. Reusing a prior "yes" flag could
1117 // incorrectly report that a different MLS or changed credentials succeeded.
1118 global $mlsimport;
1119 $mlsimport->admin->mlsimport_saas_setting_up();
1120 $mlsimport->admin->mlsimport_saas_check_mls_connection();
1121 $is_mls_connected = get_option('mlsimport_connection_test', '');
1122
1123 ob_start();
1124 if ('yes' === $is_mls_connected) {
1125 ?>
1126 <div class="mlsimport_warning mlsimport_validated">
1127 <?php esc_html_e('You’re now connected to your MLS.', 'mlsimport'); ?>
1128 </div>
1129 <?php
1130 } else {
1131 ?>
1132 <div class="mlsimport_warning">
1133 <?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'); ?>
1134 </div>
1135 <?php
1136 }
1137 $html = ob_get_clean();
1138
1139 wp_send_json_success([
1140 'message' => __('MLS data saved', 'mlsimport'),
1141 'html' => $html,
1142 'connected' => $is_mls_connected === 'yes',
1143 ]);
1144 }
1145