PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.0.6
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.0.6
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 / admin / class-mlsimport-admin.php

class-mlsimport-admin.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.0.6, at admin/class-mlsimport-admin.php

3,961 lines 163.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit; // Exit if accessed directly
4 }
5
6 /*
7 * ---------------------------------------------------------------------------
8 * FILE ROLE: admin-side controller for the whole plugin.
9 * ---------------------------------------------------------------------------
10 * This ~3,470-line class (Mlsimport_Admin) is the admin monolith referenced in
11 * CLAUDE.md. Its hooks are registered by the core Mlsimport class via the
12 * Loader. Broadly it owns:
13 * - Asset enqueue for wp-admin (styles, field-selector JS, standalone React
14 * settings app, deactivation survey, searchable selects).
15 * - Admin menu + settings pages (main options page, Import History, and the
16 * standalone theme_id 990 "Design Settings" React page).
17 * - Settings registration + validation callbacks (register_setting) for the
18 * several mlsimport_admin_* option groups.
19 * - The mlsimport_item (Import Task) metaboxes: rendering the import-parameter
20 * form and saving its post meta.
21 * - The MLS connection test and SaaS token/metadata retrieval.
22 * - Building the RESO listing-request arguments from an Import Task's meta.
23 * - The import engine: manual (AJAX), hourly cron per item, and the
24 * background/Action Scheduler batch processors.
25 * - The daily reconciliation sweep (delete/keep local listings vs. the MLS
26 * feed) with a truncated-feed safety guard.
27 * - The plugin-deactivation exit survey (~18 AJAX handlers total live here).
28 * NOTE: the enviroment/ directory name is intentionally misspelled plugin-wide;
29 * "env_data" is the active theme adapter, "mls_env_data" the MLS provider one.
30 * ---------------------------------------------------------------------------
31 */
32
33
34 /**
35 * The admin-specific functionality of the plugin.
36 *
37 * @link http://mlsimport.com/
38 * @since 1.0.0
39 *
40 * @package Mlsimport
41 * @subpackage Mlsimport/admin
42 */
43
44
45 /**
46 * The admin-specific functionality of the plugin.
47 *
48 * Defines the plugin name, version, and two examples hooks for how to
49 * enqueue the admin-specific stylesheet and JavaScript.
50 *
51 * @package Mlsimport
52 * @subpackage Mlsimport/admin
53 * @author MlsImport <office@mlsimport.com>
54 */
55 class Mlsimport_Admin {
56
57 /**
58 * The ID of this plugin.
59 *
60 * @since 1.0.0
61 * @access private
62 * @var string $plugin_name The ID of this plugin.
63 */
64 private $plugin_name;
65
66 /**
67 * The version of this plugin.
68 *
69 * @since 1.0.0
70 * @access private
71 * @var string $version The current version of this plugin.
72 */
73 private $version;
74 // Back-reference to the core Mlsimport instance (set externally).
75 public $main;
76 // ThemeImport API client instance (OAuth + all SaaS API calls).
77 public $theme_importer;
78 // Active theme adapter object (e.g. ResidenceClass); stdClass when no theme.
79 public $env_data;
80 // Active MLS provider adapter object; stdClass when none configured.
81 public $mls_env_data;
82 // Reserved handle for a batch/queue processor (declared, assigned elsewhere).
83 protected $process_all;
84 // Field-import definition array (populated per request where used).
85 public $field_import;
86 // Map of supported theme_id => human name (990 standalone, 991-994 themes).
87 public $themes;
88 /**
89 * Initialize the class and set its properties.
90 *
91 * @since 1.0.0
92 * @param string $plugin_name The name of this plugin.
93 * @param string $version The version of this plugin.
94 */
95 public function __construct( $plugin_name, $version ) {
96
97 // Store the plugin slug (used as the option-key prefix) and version.
98 $this->plugin_name = $plugin_name;
99 $this->version = $version;
100
101 // RESO fields that are enum/lookup-driven and shown on the Import Task form.
102 $this->field_import = array(
103 'City',
104 'CountyOrParish',
105 'MlsStatus',
106 'PropertySubType',
107 'PropertyType',
108 'StandardStatus',
109 'InternetEntireListingDisplayYN',
110 'InternetAddressDisplayYN',
111 );
112
113 // theme_id => adapter name. The class is derived by stripping "Wp" and
114 // appending "Class" (e.g. WpResidence -> ResidenceClass).
115 $this->themes = array(
116 990 => 'Standalone',
117 991 => 'WpResidence',
118 992 => 'Houzez',
119 993 => 'RealHomes',
120 994 => 'Wpestate',
121 );
122 }
123 /**
124 * Wire up the theme and MLS provider adapter objects for this request.
125 *
126 * Reads the configured theme_id, resolves the theme adapter class name, and
127 * instantiates both the theme adapter (env_data) and the MLS provider adapter
128 * (mls_env_data); missing config falls back to an empty stdClass.
129 *
130 * @param string $plugin_name Plugin slug passed to ThemeImport.
131 * @param string $mls_enviroment MLS provider adapter base name (e.g. BridgeReso).
132 * @param string $theme_enviroment Ignored on input; recomputed from the saved theme_id.
133 * @since 1.0.0
134 */
135 public function admin_setup( $plugin_name, $mls_enviroment, $theme_enviroment ) {
136
137 // Load saved options and resolve the configured theme id (0 when unset).
138 $options = get_option( $this->plugin_name . '_admin_options' );
139 $theme_id = 0;
140 if ( isset( $options['mlsimport_theme_used'] ) ) {
141 $theme_id = intval( $options['mlsimport_theme_used'] );
142 }
143 $themes = $this->themes;
144
145 // Map the theme id to its adapter name; blank when the id is unknown.
146 $theme_enviroment = '';
147 if ( isset( $themes[ $theme_id ] ) ) {
148 $theme_enviroment = $themes[ $theme_id ];
149 }
150
151 // Always create the API client.
152 $this->theme_importer = new ThemeImport( $plugin_name );
153
154 $options_api = get_option( $this->plugin_name . '_admin_options' );
155
156 // Instantiate the theme adapter (WpResidence -> ResidenceClass), else stub.
157 if ( '' !== $theme_enviroment ) {
158 $classname = str_replace('Wp','',$theme_enviroment ). 'Class';
159 $this->env_data = new $classname();
160 } else {
161 $this->env_data = new stdClass();
162 }
163
164 // Instantiate the MLS provider adapter (name + "Class"), else stub.
165 if ( '' !== $mls_enviroment ) {
166 $mls_classname = $mls_enviroment . 'Class';
167
168 $this->mls_env_data = new $mls_classname( $this->theme_importer );
169 } else {
170 $this->mls_env_data = new stdClass();
171 }
172 }
173
174 /**
175 * Register the stylesheets for the admin area.
176 *
177 * Enqueues the main admin CSS plus the onboarding and field-selector styles.
178 *
179 * @since 1.0.0
180 */
181 public function enqueue_styles() {
182 // Main admin stylesheet.
183 wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/mlsimport-admin.css', array(), MLSIMPORT_VERSION, 'all' );
184 // Onboarding wizard styles.
185 wp_enqueue_style( 'mlsimport-onboarding', plugin_dir_url( __FILE__ ) . 'css/mlsimport-onboarding.css', array(), MLSIMPORT_VERSION, 'all' );
186 // Drag-and-drop field selector styles.
187 wp_enqueue_style( 'mlsimport-field-selector', plugin_dir_url( __FILE__ ) . 'css/mlsimport-field-selector.css', array(), MLSIMPORT_VERSION, 'all' );
188 }
189
190
191
192
193 /**
194 * Register the JavaScript for the admin area.
195 *
196 * Enqueues the core admin script, the field-selector + progressive-save
197 * scripts, and conditionally (by page/hook) injects inline bootstraps for
198 * metadata fetch and MLS autocomplete, plus the searchable-select and
199 * deactivation-survey scripts on their respective screens.
200 *
201 * @param string $hook_suffix Current admin page hook suffix.
202 * @since 1.0.0
203 */
204 public function enqueue_scripts($hook_suffix) {
205 // jQuery UI autocomplete backs the MLS-name search box.
206 wp_enqueue_script( 'jquery-ui-autocomplete' );
207 // Pull the cached MLS list (used later for the autocomplete bootstrap).
208 $mls_import_list = mlsimport_saas_request_list();
209 // Core admin script + AJAX endpoint.
210 wp_enqueue_script( 'mlsimport-admin', plugin_dir_url( __FILE__ ) . 'js/mlsimport-admin.js', array( 'jquery' ), $this->version, true );
211 wp_localize_script(
212 'mlsimport-admin',
213 'mlsimport_vars',
214 array(
215 'ajax_url' => admin_url( 'admin-ajax.php' )
216 )
217 );
218
219 // Field-selector UI depends on jQuery UI sortable + tooltip.
220 wp_enqueue_script( 'mlsimport-field-selector', plugin_dir_url( ( __FILE__ ) ) . 'js/mlsimport-field-selector.js', array( 'jquery', 'jquery-ui-sortable', 'jquery-ui-tooltip' ), '1.0.0', true );
221
222 // Pass AJAX parameters to script
223 wp_localize_script( 'mlsimport-field-selector', 'mlsimport_params', array(
224 'ajax_url' => admin_url( 'admin-ajax.php' ),
225 'nonce' => wp_create_nonce( 'mlsimport_field_selector_nonce' )
226 ));
227
228
229 // Progressive save batches field-selector changes; depends on it.
230 wp_enqueue_script( 'mlsimport-progressive-save', plugin_dir_url( ( __FILE__ ) ) . 'js/progressive-save.js', array('mlsimport-field-selector' ), '1.0.0', true );
231
232
233
234 // On the settings page Field Options tab: if metadata was never fetched,
235 // auto-trigger the metadata pull on DOM ready.
236 if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
237 isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' &&
238 isset($_GET['tab']) && $_GET['tab'] === 'field_options') {
239 $mlsimport_mls_metadata_populated = get_option( 'mlsimport_mls_metadata_populated', '' );
240 if ( 'yes' !== $mlsimport_mls_metadata_populated ) {
241 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
242 wp_add_inline_script('mlsimport-admin', $inline_script);
243 }
244 }
245
246 // Same auto-metadata bootstrap, but for the onboarding wizard screen.
247 if (
248 'admin_page_mlsimport-onboarding' === $hook_suffix &&
249 isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding'
250 ) {
251 $mlsimport_mls_metadata_populated = get_option('mlsimport_mls_metadata_populated', '');
252 if ('yes' !== $mlsimport_mls_metadata_populated) {
253 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
254 wp_add_inline_script('mlsimport-admin', $inline_script);
255 }
256 }
257
258
259
260
261 // On the settings Display Options tab (or the page with no tab), seed the
262 // MLS-name autocomplete with the fetched list when it is not an array.
263 if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
264 ( isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && isset($_GET['tab']) && $_GET['tab'] === 'display_options') ||
265 (isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && !isset($_GET['tab']) ) ) {
266
267 // Re-fetch the MLS list and, when it is a raw string payload,
268 // hand it to the JS autocomplete initializer.
269 $mls_import_list = mlsimport_saas_request_list();
270 if(!is_array($mls_import_list)){
271 $inline_script = 'jQuery(document).ready(function($){ var autofill='.wp_kses_post($mls_import_list).';mlsimport_autocomplte_mls_selection(autofill); });';
272 wp_add_inline_script('mlsimport-admin', $inline_script);
273 }
274 }
275
276 // Searchable City/County multi-select — only on the Import Task edit screen.
277 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
278 $post_type = $screen ? (string) $screen->post_type : '';
279 if ( $this->mlsimport_is_import_task_edit_screen( (string) $hook_suffix, $post_type ) ) {
280 wp_enqueue_script( 'mlsimport-searchable-select', plugin_dir_url( __FILE__ ) . 'js/mlsimport-searchable-select.js', array(), MLSIMPORT_VERSION, true );
281 }
282
283 // Deactivation exit survey — only needed on the Plugins screen.
284 if ( 'plugins.php' === $hook_suffix ) {
285 // Enqueue the survey modal script and hand it the nonce, options and i18n.
286 wp_enqueue_script( 'mlsimport-deactivation-survey', plugin_dir_url( __FILE__ ) . 'js/mlsimport-deactivation-survey.js', array( 'jquery' ), MLSIMPORT_VERSION, true );
287 wp_localize_script( 'mlsimport-deactivation-survey', 'mlsimport_deact_survey', array(
288 'ajax_url' => admin_url( 'admin-ajax.php' ),
289 'nonce' => wp_create_nonce( 'mlsimport_exit_survey' ),
290 'plugin_basename' => plugin_basename( MLSIMPORT_PLUGIN_PATH . 'mlsimport.php' ),
291 'options' => $this->get_exit_survey_options(),
292 'i18n' => array(
293 'title' => esc_html__( 'Before you go — quick question', 'mlsimport' ),
294 'intro' => esc_html__( 'Why are you deactivating MLS Import? Your answer helps us improve.', 'mlsimport' ),
295 'other_placeholder' => esc_html__( 'Tell us more (optional)', 'mlsimport' ),
296 'submit' => esc_html__( 'Submit & Deactivate', 'mlsimport' ),
297 'skip' => esc_html__( 'Skip & Deactivate', 'mlsimport' ),
298 ),
299 ) );
300 }
301
302 }
303
304
305
306
307
308 /**
309 * Register the administration menu for this plugin into the WordPress Dashboard menu.
310 *
311 * Adds the top-level "MLS Import Settings" page and the "Import History"
312 * submenu; in standalone mode it also adds the separate "Design Settings"
313 * React page and enqueues its bundle only on that hook.
314 *
315 * @since 1.0.0
316 */
317 public function add_plugin_admin_menu() {
318 // Top-level settings menu (capability: administrator).
319 add_menu_page(
320 esc_html__( 'MLS Import Settings', 'mlsimport'),
321 esc_html__( 'MLS Import Settings', 'mlsimport' ),
322 'administrator',
323 'mlsimport_plugin_options',
324 array( $this, 'display_plugin_setup_page' ),
325 MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png',
326 // Fractional slot right after Import Tasks (21). Fractions are only
327 // honoured by add_menu_page, so the two settings pages take 21.1/21.2
328 // and leave integer slots 22/23 for the Properties/Agents CPTs — keeping
329 // all MLSImport menus grouped above core Comments (25).
330 21.1
331 );
332
333 // Import History submenu under the settings menu.
334 add_submenu_page(
335 'mlsimport_plugin_options',
336 esc_html__( 'Import History', 'mlsimport' ),
337 esc_html__( 'Import History', 'mlsimport' ),
338 'administrator',
339 'mlsimport_history',
340 array( $this, 'display_history_page' )
341 );
342
343 // Standalone (theme_id 990) front-end design. Its own top-level menu,
344 // deliberately separate from MLS import settings because it controls
345 // the public-facing visuals. React app; see admin/settings-app/.
346 if ( function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode() ) {
347 // Separate top-level menu for the standalone front-end design app.
348 $standalone_hook = add_menu_page(
349 esc_html__( 'MLS Import Design Settings', 'mlsimport' ),
350 esc_html__( 'MLS Import Design Settings', 'mlsimport' ),
351 'manage_options',
352 'mlsimport_standalone_settings',
353 array( $this, 'display_standalone_settings_page' ),
354 MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png',
355 21.2
356 );
357
358 // Load the React bundle only when this exact page hook is rendering.
359 add_action(
360 'admin_enqueue_scripts',
361 function ( $current_hook ) use ( $standalone_hook ) {
362 if ( $current_hook === $standalone_hook ) {
363 $this->enqueue_standalone_settings_app();
364 }
365 }
366 );
367 }
368 }
369
370 /**
371 * Render the Standalone Design page — just the React mount point. All
372 * fields, save and validation live in the app (admin/settings-app/) and the
373 * settings REST endpoint.
374 *
375 * @return void
376 */
377 public function display_standalone_settings_page() {
378 echo '<div class="wrap">';
379 echo '<h1>' . esc_html__( 'MLS Import Design Settings', 'mlsimport' ) . '</h1>';
380 echo '<div id="mlsimport-standalone-app"></div>';
381 echo '</div>';
382 }
383
384 /**
385 * Enqueue the compiled Standalone Design React bundle and its WP component
386 * styles. Dependencies + cache-busting version come from the build's
387 * generated index.asset.php.
388 *
389 * @return void
390 */
391 private function enqueue_standalone_settings_app() {
392 // The build emits index.asset.php with dependencies + a content hash;
393 // bail quietly if the app was never built.
394 $asset_file = MLSIMPORT_PLUGIN_PATH . 'admin/settings-app/build/index.asset.php';
395 if ( ! file_exists( $asset_file ) ) {
396 return;
397 }
398 $asset = require $asset_file;
399
400 // The MLS-logo control opens the native WordPress media modal (wp.media).
401 wp_enqueue_media();
402
403 // wp-color-picker (Iris) powers the native colour control in the React app;
404 // it pulls in jQuery + iris, so the window.jQuery global is available.
405 wp_enqueue_script(
406 'mlsimport-standalone-settings',
407 MLSIMPORT_PLUGIN_URL . 'admin/settings-app/build/index.js',
408 array_merge( $asset['dependencies'], array( 'wp-color-picker' ) ),
409 $asset['version'],
410 true
411 );
412 // Enable JS translation loading for the app's strings.
413 wp_set_script_translations( 'mlsimport-standalone-settings', 'mlsimport' );
414
415 // The field tree the React app renders from — tabs/sub-tabs/fields generated
416 // from the ONE registry (mlsimport_standalone_settings_app_config). The app
417 // reads window.mlsimportFields instead of a hard-coded list, so a field added
418 // to the registry appears here (and, via the schema, in the Customizer) with
419 // no JS change.
420 if ( function_exists( 'mlsimport_standalone_settings_app_config' ) ) {
421 wp_add_inline_script(
422 'mlsimport-standalone-settings',
423 'window.mlsimportFields = ' . wp_json_encode( mlsimport_standalone_settings_app_config() ) . ';',
424 'before'
425 );
426 }
427
428 // Feed the "Arrange Sections" control its catalog (slug + label) from the
429 // property section registry, so the list matches what the front end renders.
430 if ( function_exists( 'mlsimport_standalone_section_catalog' ) ) {
431 $catalog = array();
432 foreach ( mlsimport_standalone_section_catalog() as $slug => $label ) {
433 $catalog[] = array( 'slug' => $slug, 'label' => $label );
434 }
435 wp_add_inline_script(
436 'mlsimport-standalone-settings',
437 'window.mlsimportSections = ' . wp_json_encode( $catalog ) . ';',
438 'before'
439 );
440 }
441
442 // The Overview "Arrange Fields" control reads the Overview tile catalog — the
443 // stat tiles the Overview section can draw (Updated, MLS #, Bedrooms, …).
444 if ( function_exists( 'mlsimport_standalone_overview_fields_catalog' ) ) {
445 $overview_fields = array();
446 foreach ( mlsimport_standalone_overview_fields_catalog() as $slug => $label ) {
447 $overview_fields[] = array( 'slug' => $slug, 'label' => $label );
448 }
449 wp_add_inline_script(
450 'mlsimport-standalone-settings',
451 'window.mlsimportOverviewFields = ' . wp_json_encode( $overview_fields ) . ';',
452 'before'
453 );
454 }
455
456 // The agent "Arrange Sections" control reads its own catalog (the agent page's
457 // reorderable content-column sections), kept separate from the property catalog.
458 if ( function_exists( 'mlsimport_standalone_agent_section_catalog' ) ) {
459 $agent_catalog = array();
460 foreach ( mlsimport_standalone_agent_section_catalog() as $slug => $label ) {
461 $agent_catalog[] = array( 'slug' => $slug, 'label' => $label );
462 }
463 wp_add_inline_script(
464 'mlsimport-standalone-settings',
465 'window.mlsimportAgentSections = ' . wp_json_encode( $agent_catalog ) . ';',
466 'before'
467 );
468 }
469
470 // The archive "Taxonomy filters" control reads its own catalog (the search
471 // form's toggleable filter fields), so the on/off toggle list matches what
472 // the taxonomy/CPT archive search bar can render.
473 if ( function_exists( 'mlsimport_standalone_archive_filters_catalog' ) ) {
474 $archive_filters = array();
475 foreach ( mlsimport_standalone_archive_filters_catalog() as $slug => $label ) {
476 $archive_filters[] = array( 'slug' => $slug, 'label' => $label );
477 }
478 wp_add_inline_script(
479 'mlsimport-standalone-settings',
480 'window.mlsimportArchiveFilters = ' . wp_json_encode( $archive_filters ) . ';',
481 'before'
482 );
483 }
484
485 // The saved MLS logo's preview URL, so the media control can show the
486 // current image before the user opens the picker.
487 if ( function_exists( 'mlsimport_standalone_mls_logo_url' ) ) {
488 wp_add_inline_script(
489 'mlsimport-standalone-settings',
490 'window.mlsimportLogoUrl = ' . wp_json_encode( mlsimport_standalone_mls_logo_url() ) . ';',
491 'before'
492 );
493 }
494
495 // Component + color-picker styles the React controls rely on, then the
496 // app's own stylesheet. Cache-bust by file mtime so edits to the CSS are
497 // picked up immediately — the plugin version (MLSIMPORT_VERSION) doesn't
498 // change between design tweaks, so keying the ?ver on it left browsers
499 // serving a stale cached copy under the same URL.
500 $standalone_css_path = MLSIMPORT_PLUGIN_PATH . 'admin/css/mlsimport-standalone-settings.css';
501 $standalone_css_ver = file_exists( $standalone_css_path )
502 ? (string) filemtime( $standalone_css_path )
503 : ( defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : false );
504 wp_enqueue_style( 'wp-components' );
505 wp_enqueue_style( 'wp-color-picker' );
506 wp_enqueue_style(
507 'mlsimport-standalone-settings',
508 MLSIMPORT_PLUGIN_URL . 'admin/css/mlsimport-standalone-settings.css',
509 array( 'wp-components' ),
510 $standalone_css_ver
511 );
512 }
513
514 /**
515 * Renders the Import History admin page.
516 *
517 * @return void
518 */
519 public function display_history_page() {
520 // Delegates the whole page to the history partial template.
521 include_once plugin_dir_path( __FILE__ ) . 'partials/mlsimport-history.php';
522 }
523
524
525
526
527
528
529
530
531 /**
532 * Add a "Settings" action link to this plugin's row on the Plugins page.
533 *
534 * @param array $links Existing plugin action links.
535 * @return array Links with the Settings link prepended.
536 * @since 1.0.0
537 */
538 public function add_action_links( $links ) {
539 // Build the Settings link and place it before the default action links.
540 $settings_link = array(
541 '<a href="' . admin_url( 'admin.php?page=mlsimport_plugin_options' ) . '">' . esc_html__( 'Settings', 'mlsimport') . '</a>',
542 );
543 return array_merge( $settings_link, $links );
544 }
545
546
547
548
549
550
551
552
553 /**
554 * Render the main settings page for this plugin.
555 *
556 * Loads the admin-display partial (whose filename is prefixed with the slug).
557 *
558 * @since 1.0.0
559 */
560 public function display_plugin_setup_page() {
561 // Delegates the whole page to the slug-prefixed admin-display partial.
562 include_once 'partials/' . $this->plugin_name . '-admin-display.php';
563 }
564
565
566
567
568
569
570 /**
571 * Sanitize/whitelist the main plugin options on save (register_setting callback).
572 *
573 * Copies only the known keys from $input (esc_attr'd), then invalidates the
574 * connection-test / metadata flags and cached tokens/schema so the next page
575 * load re-tests the connection with the new credentials.
576 *
577 * @param array $input Raw submitted options.
578 * @return array Whitelisted, escaped options.
579 * @since 1.0.0
580 */
581 public function validate_admin_options( $input ) {
582
583 // Whitelist of accepted option keys (value = label/help metadata, unused
584 // beyond documentation here); anything not listed is dropped on save.
585 $valid = array();
586 $settings_list = array(
587 'auth_username' => array(
588 'name' => esc_html__( 'Api auth_username ', 'mlsimport' ),
589 'details' => 'to be added',
590 ),
591 'auth_password' => array(
592 'name' => esc_html__( 'Api auth_password', 'mlsimport' ),
593 'details' => 'to be added',
594 ),
595 'client_id' => array(
596 'name' => esc_html__( 'Api client_id', 'mlsimport' ),
597 'details' => 'to be added',
598 ),
599 'client_secret' => array(
600 'name' => esc_html__( 'client_secret', 'mlsimport' ),
601 'details' => 'to be added',
602 ),
603 'redirect_uri' => array(
604 'name' => esc_html__( 'redirect_uri', 'mlsimport' ),
605 'details' => 'to be added',
606 ),
607 'title_format' => array(
608 'name' => esc_html__( 'title_format', 'mlsimport' ),
609 'details' => 'to be added',
610 ),
611 'force_rand' => array(
612 'name' => esc_html__( 'title_format', 'mlsimport' ),
613 'details' => 'to be added',
614 ),
615 'mlsimport_username' => array(
616 'name' => esc_html__( 'MLSImport.com Username (not your email)', 'mlsimport' ),
617 'details' => 'to be added',
618 ),
619 'mlsimport_password' => array(
620 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ),
621 'details' => 'to be added',
622 ),
623 'mlsimport_mls_name' => array(
624 'name' => esc_html__( 'MLSImport Name', 'mlsimport' ),
625 'details' => 'to be added',
626 ),
627 'mlsimport_mls_token' => array(
628 'name' => esc_html__( 'MLSImport Token', 'mlsimport' ),
629 'details' => 'to be added',
630 ),
631
632 'mlsimport_tresle_client_id' => array(
633 'name' => esc_html__( 'MLSImport Tresle Client id', 'mlsimport' ),
634 'details' => 'to be added',
635 ),
636
637 'mlsimport_tresle_client_secret' => array(
638 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
639 'details' => 'to be added',
640 ),
641
642 'mlsimport_connectmls_username' => array(
643 'name' => esc_html__( 'MLSImport ConnectMLS Username', 'mlsimport' ),
644 'details' => 'to be added',
645 ),
646
647 'mlsimport_connectmls_password' => array(
648 'name' => esc_html__( 'MLSImport ConnectMLS Password', 'mlsimport' ),
649 'details' => 'to be added',
650 ),
651
652 'mlsimport_rapattoni_client_id' => array(
653 'name' => esc_html__( 'MLSImport Rapattoni Client id','mlsimport'),
654 'details' => 'to be added',
655 ),
656
657 'mlsimport_rapattoni_client_secret' => array(
658 'name' => esc_html__( 'MLSImport Rapattoni Secret', 'mlsimport' ),
659 'details' => 'to be added',
660 ),
661
662 'mlsimport_rapattoni_username' => array(
663 'name' => esc_html__( 'MLSImport Rapattoni Username', 'mlsimport' ),
664 'details' => 'to be added',
665 ),
666
667 'mlsimport_rapattoni_password' => array(
668 'name' => esc_html__( 'MLSImport Rapattoni Password', 'mlsimport' ),
669 'details' => 'to be added',
670 ),
671
672 'mlsimport_paragon_client_id' => array(
673 'name' => esc_html__( 'MLSImport Paragon Client id','mlsimport' ),
674 'details' => 'to be added',
675 ),
676
677 'mlsimport_paragon_client_secret' => array(
678 'name' => esc_html__( 'MLSImport Paragon Secret', 'mlsimport' ),
679 'details' => 'to be added',
680 ),
681 'mlsimport_realtorca_client_id' => array(
682 'name' => esc_html__( 'MLSImport Realtor.ca Client id','mlsimport' ),
683 'details' => 'to be added',
684 ),
685
686 'mlsimport_realtorca_client_secret' => array(
687 'name' => esc_html__( 'MLSImport Realtor.ca Secret', 'mlsimport' ),
688 'details' => 'to be added',
689 ),
690 'mlsimport_brightmls_client_id' => array(
691 'name' => esc_html__( 'MLSImport BrightMLS Client id', 'mlsimport' ),
692 'details' => 'to be added',
693 ),
694
695 'mlsimport_brightmls_client_secret' => array(
696 'name' => esc_html__( 'MLSImport BrightMLS Secret', 'mlsimport' ),
697 'details' => 'to be added',
698 ),
699
700 'mlsimport_theme_used' => array(
701 'name' => esc_html__( 'Your Wordpress Theme', 'mlsimport' ),
702 'details' => 'to be added',
703 ),
704 'mlsimport_mls_name_front' => array(
705 'name' => '',
706 'details' => 'to be added',
707 ),
708 'mlsimport-disable-logs' => array(
709 'name' => '',
710 'details' => 'to be added',
711 ),
712 );
713
714 // Copy each whitelisted key, escaping the value; missing/empty => ''.
715 foreach ( $settings_list as $key => $setting ) {
716 $valid[ $key ] = ( isset( $input[ $key ] ) && ! empty( $input[ $key ] ) ) ? esc_attr( $input[ $key ] ) : '';
717 }
718
719 // Credentials may have changed: force a fresh connection test + metadata pull.
720 delete_option( 'mlsimport_connection_test' );
721 delete_option( 'mlsimport_mls_metadata_populated' );
722
723 // Reset cached encoding and drop cached token/schema transients.
724 update_option( 'mlsimport_encoding_array', '' );
725 delete_transient( 'mlsimport_token_request' );
726 delete_transient( 'mlsimport_schema' );
727 delete_transient( 'mlsimport_plugin_data_schema' );
728
729 delete_transient( 'mlsimport_saas_token' );
730 return $valid;
731 }
732
733
734
735
736
737 /**
738 * Sanitize the selected-fields option on save (register_setting callback).
739 *
740 * Iterates the MLS metadata field list and copies, for each known field, the
741 * display/admin flags, label, post-meta and taxonomy mappings and drag order.
742 * On a first save it seeds sensible defaults via mlsimport_seed_field_defaults.
743 *
744 * @param array $input Raw submitted field-select data.
745 * @return array Whitelisted field configuration.
746 * @since 1.0.0
747 */
748 public function validate_admin_fields_select( $input ) {
749 $valid = array();
750
751 // The authoritative field list comes from the fetched MLS metadata.
752 $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' );
753 $metadata_api_call = json_decode( $mlsimport_mls_metadata_mls_data, true );
754
755 // Only accept keys that exist in the MLS metadata.
756 foreach ( $metadata_api_call as $key => $value ) {
757 // Front-end display flag for this field.
758 if ( isset( $input['mls-fields'][ $key ] ) ) {
759 $valid['mls-fields'][ $key ] = esc_attr( $input['mls-fields'][ $key ] );
760 }
761
762 // Admin-display flag + its associated label/mapping/order settings.
763 if ( isset( $input['mls-fields-admin'][ $key ] ) ) {
764 $valid['mls-fields-admin'][ $key ] = esc_attr( $input['mls-fields-admin'][ $key ] );
765 $valid['mls-fields-label'][ $key ] = esc_attr( $input['mls-fields-label'][ $key ] );
766 $valid['mls-fields-map-postmeta'][ $key ] = esc_attr( $input['mls-fields-map-postmeta'][ $key ] );
767 $valid['mls-fields-map-taxonomy'][ $key ] = esc_attr( $input['mls-fields-map-taxonomy'][ $key ] );
768 $valid['field_order'][ $key ] = esc_attr( $input['field_order'][ $key ] );
769 }
770 }
771 //$valid['mls-fields-admin']['force_rand'] = esc_attr( $input['mls-fields-admin']['force_rand'] );
772
773 // First save has nothing configured yet: seed the drag order and hide the
774 // plumbing fields (keys, timestamps, coordinates) from visitors, so the
775 // property page reads sensibly out of the box. Never overwrites a site the
776 // user has already arranged.
777 if ( function_exists( 'mlsimport_seed_field_defaults' ) ) {
778 $valid = mlsimport_seed_field_defaults( $valid );
779 }
780
781 return $valid;
782 }
783
784 /**
785 * Validate the MLS-sync option group on save (register_setting callback).
786 *
787 * Copies a fixed whitelist of sync/import parameter keys straight through.
788 *
789 * @param array $input Raw submitted sync settings.
790 * @return array Whitelisted sync settings.
791 * @since 1.0.0
792 */
793 public function validate_admin_mls_sync( $input ) {
794 $valid = array();
795
796 // Fixed whitelist of sync parameters (price, title, agent/user, the enum
797 // filters and their "select-all" _check flags).
798 $field_import = array( 'force_rand', 'min_price', 'max_price', 'title_format', 'property_agent', 'property_user', 'City', 'City_check', 'CountyOrParish', 'CountyOrParish_check', 'MlsStatus', 'MlsStatus_check', 'PropertySubType', 'PropertySubType_check', 'PropertyType', 'PropertyType_check',
799 'StandardStatus_delete', 'StandardStatus_delete_check', 'InternetEntireListingDisplayYN', 'InternetAddressDisplayYN' );
800 // Pass each whitelisted key through unchanged.
801 foreach ( $field_import as $key ) {
802 $valid[ $key ] = $input[ $key ];
803 }
804
805 return $valid;
806 }
807
808
809 /**
810 * Validate the administrative options group on save (register_setting callback).
811 *
812 * Only carries the raw "import" payload through (a JSON blob of exported settings).
813 *
814 * @param array $input Raw submitted administrative options.
815 * @return array Whitelisted administrative options.
816 * @since 1.0.0
817 */
818 public function validate_administrative_options( $input ) {
819
820 $valid = array();
821
822 // Pass the single 'import' payload through.
823 $field_import = array( 'import' );
824 foreach ( $field_import as $key ) {
825 $valid[ $key ] = $input[ $key ];
826 }
827
828 return $valid;
829 }
830
831 /**
832 * Validate the import-options group on save (register_setting callback).
833 *
834 * Casts import_number to int, and when an 'import' JSON payload is present it
835 * restores the field-select / mls-sync / import-options / transients options
836 * from it (used by the settings import/export feature).
837 *
838 * @param array $input Raw submitted import options.
839 * @return array Whitelisted import options.
840 * @since 1.0.0
841 */
842 public function validate_admin_import_options( $input ) {
843 $valid = array();
844
845 // import_number is numeric-only.
846 $field_import = array( 'import_number' );
847 foreach ( $field_import as $key ) {
848 $valid[ $key ] = intval( $input[ $key ] );
849 }
850
851 // When an exported-settings JSON blob is supplied, decode it and restore
852 // the four related option groups from it.
853 if ( isset( $input['import'] ) && '' !== $input['import'] ) {
854 $decode = json_decode( $input['import'] );
855 update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
856 update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
857 update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
858 update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] );
859 }
860
861 return $valid;
862 }
863
864
865
866
867
868
869 /**
870 * Register all plugin option groups with the Settings API and bind each to
871 * its validation callback. Hooked on admin_init.
872 */
873 public function options_update() {
874 // One register_setting per option group -> validate_* sanitizer above.
875 register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) );
876 register_setting( $this->plugin_name . '_admin_fields_select', $this->plugin_name . '_admin_fields_select', array( $this, 'validate_admin_fields_select' ) );
877 register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) );
878 register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) );
879 register_setting( $this->plugin_name . '_administrative_options', $this->plugin_name . '_administrative_options', array( $this, 'validate_administrative_options' ) );
880 // The standalone option is registered in class-mlsimport-standalone-settings.php
881 // (on init, with show_in_rest) so the dedicated React design page can read/write it.
882 }
883
884 /**
885 * Update-option hook for the administrative options group.
886 *
887 * When the administrative options carry an 'import' JSON payload, decode it
888 * and restore the field-select / mls-sync / import-options option groups.
889 */
890 public function update_option_mlsimport_administrative_options() {
891 // Read the saved administrative options and, if present, restore the
892 // three related option groups from the embedded JSON payload.
893 $import = get_option( 'mlsimport_administrative_options' );
894 if ( '' !== $import ) {
895 $decode = json_decode( $import['import'], true );
896 update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
897 update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
898 update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
899 }
900 }
901
902 /**
903 * Update-option hook for the field-select group: ask the active theme
904 * adapter to (re)register its custom fields/taxonomies for the mapped fields.
905 */
906 public function update_option_mlsimport_admin_fields_select() {
907
908 // Delegate to the theme adapter to sync its custom fields.
909 $this->env_data->enviroment_custom_fields( $this->plugin_name );
910 }
911
912
913 /**
914 * Register the "Hidden Fields" metabox on the theme's property post type.
915 *
916 * Only added when the theme adapter exposes get_property_post_type().
917 */
918 public function mlsimport_meta_options() {
919 // Add the metabox to whatever post type the active theme uses for listings.
920 if ( method_exists( $this->env_data, 'get_property_post_type' ) ) {
921 add_meta_box( 'mlsimport_hidden_fields', esc_html__( 'Mls Import Hidden Fields', 'mlsimport' ), array( $this, 'mlsimport_hidden_fields' ), $this->env_data->get_property_post_type(), 'normal', 'low' );
922 }
923 }
924
925 /**
926 * Render the "Hidden Fields" metabox for a single property post.
927 *
928 * Shows the ListingKey, the source Import Task (inserted/updated), any
929 * protected statuses, every admin-flagged imported field value, and the
930 * property change history.
931 */
932 public function mlsimport_hidden_fields() {
933 global $post;
934
935 // Field-select config drives which imported meta values to display.
936 $options = get_option( $this->plugin_name . '_admin_fields_select' );
937
938 // Which Import Task created / last updated this property, and its RESO key.
939 $MLSimport_item_inserted = get_post_meta( $post->ID, 'MLSimport_item_inserted', true );
940 $MLSimport_item_updated = get_post_meta( $post->ID, 'MLSimport_item_updated', true );
941 $listing_key = get_post_meta( $post->ID, 'ListingKey', true );
942
943 // Get the import task ID to retrieve protected statuses
944 // (prefer the inserting task, fall back to the updating task).
945 $import_task_id = !empty( $MLSimport_item_inserted ) ? $MLSimport_item_inserted : ( !empty( $MLSimport_item_updated ) ? $MLSimport_item_updated : null );
946 $mlsImportItemStatusProtect = $import_task_id ? get_post_meta( $import_task_id, 'mlsimport_item_standardstatusprotect', true ) : null;
947
948 // Check if the ListingKey exists
949 if ( !empty( $listing_key ) ) {
950 echo 'ListingKey: ' . esc_html( $listing_key ) . '<br>';
951 }
952
953 // Check if MLSimport_item_inserted exists
954 if ( !empty( $MLSimport_item_inserted ) ) {
955 echo 'Added via MLS item id: ' . esc_html( $MLSimport_item_inserted ) . ' - ' . esc_html( get_the_title( $MLSimport_item_inserted ) ) . '<br>';
956 }
957
958 // Check if MLSimport_item_updated exists
959 if ( !empty( $MLSimport_item_updated ) ) {
960 echo 'Updated via MLS item id: ' . esc_html( $MLSimport_item_updated ) . ' - ' . esc_html( get_the_title( $MLSimport_item_updated ) ) . '<br>';
961 }
962
963 // Show any protected statuses (array or scalar) configured on the task.
964 if(!empty($mlsImportItemStatusProtect)) {
965 if(is_array($mlsImportItemStatusProtect)) {
966 echo 'Protected statuses: ' . esc_html( implode(', ', $mlsImportItemStatusProtect) ) . '<br>';
967 } else {
968 echo 'Protected statuses: ' . esc_html($mlsImportItemStatusProtect) . '<br>';
969 }
970 }
971
972 // Print each admin-flagged field: label + stored meta value.
973 foreach ( ( is_array( $options ) && ! empty( $options['mls-fields-admin'] ) ? $options['mls-fields-admin'] : array() ) as $key => $value ) {
974 // Only fields explicitly marked for admin display (flag === 1).
975 if ( 1 === intval($options['mls-fields-admin'][ $key ] ) ) {
976 // Prefer a custom label if one was set for this field.
977 $display_label = $key;
978 if ( isset( $options['mls-fields-label'][ $key ] ) && '' !== $options['mls-fields-label'][ $key ] ) {
979 $display_label = $options['mls-fields-label'][ $key ];
980 }
981
982 // Resolve the stored value. Standalone (990) stores every imported
983 // field as mlsimport_<Field> (with an _x_ fallback); the theme modes
984 // store them lowercase (except ListingKey). Reading the wrong casing
985 // is why hidden fields (e.g. ParcelNumber) showed here without a value.
986 if ( function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode() && function_exists( 'mlsimport_property_field_value' ) ) {
987 $field_value = mlsimport_property_field_value( (int) $post->ID, (string) $key );
988 } else {
989 $meta_key = ( 'ListingKey' !== $key ) ? strtolower( $key ) : $key;
990 $field_value = (string) get_post_meta( $post->ID, $meta_key, true );
991 }
992 ?>
993
994 <strong><?php echo esc_html($display_label);?>:</strong>
995 <?php echo esc_html( $field_value ); ?> </br>
996 <?php
997 }
998 }
999 ?>
1000
1001 <h2 style="font-weight:bold;padding-left:0px;">Mls Import History</h2>
1002 <?php
1003 // Property change history (only populated when history logging is enabled).
1004 $meta = get_post_meta( $post->ID, 'mlsimport_property_history', true );
1005 if ( '' === trim( $meta ) ) { ?>
1006 <strong>Property history is blank - you can enable it in Settings/ Tools page </strong>
1007 <?php
1008 } else {
1009 print wp_kses_post($meta);
1010 }
1011 }
1012
1013
1014
1015
1016 /**
1017 * AJAX (Tools page): clear all MLSImport caches/transients and the
1018 * metadata-populated flag, forcing the next request to re-fetch everything.
1019 */
1020 function mlsimport_delete_cache() {
1021
1022 // CSRF: Tools-page nonce.
1023 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1024
1025 // Drop every cached token/metadata/schema transient.
1026 delete_transient( 'mlsimport_token_request' );
1027 delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
1028 delete_transient( 'mls_import_meta_enums' );
1029 delete_transient( 'mls_import_meta' );
1030 delete_transient( 'mlsimport_plugin_data_schema' );
1031 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1032 delete_transient( 'mlsimport_saas_token' );
1033
1034 // Force a fresh metadata pull next load.
1035 delete_option( 'mlsimport_mls_metadata_populated' );
1036
1037 die( 'deleted' );
1038 }
1039
1040 /**
1041 * AJAX (Tools page): reset the field-mapping configuration so the field
1042 * selector starts fresh (also clears the metadata-populated flag).
1043 */
1044 function mlsimport_clear_fields_data() {
1045
1046 // CSRF: Tools-page nonce.
1047 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1048
1049 // Wipe the metadata flag and the saved field-select configuration.
1050 delete_option( 'mlsimport_mls_metadata_populated' );
1051 delete_option( 'mlsimport_admin_fields_select' );
1052
1053 die( 'deleted' );
1054 }
1055
1056 /**
1057 * AJAX (Tools page): return the terms of a taxonomy for the "delete
1058 * properties by term" picker. Admin-only; validates the taxonomy exists.
1059 *
1060 * @return void Emits a JSON success payload of {slug,name,count} rows.
1061 */
1062 function mlsimport_get_taxonomy_terms() {
1063 // CSRF + capability.
1064 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1065 if ( ! current_user_can( 'administrator' ) ) {
1066 wp_send_json_error( 'Unauthorized' );
1067 }
1068
1069 // Reject unknown taxonomies.
1070 $taxonomy = sanitize_text_field( wp_unslash( $_POST['taxonomy'] ) );
1071 if ( ! taxonomy_exists( $taxonomy ) ) {
1072 wp_send_json_error( 'Invalid taxonomy' );
1073 }
1074
1075 // Fetch all terms (including empties) and flatten to slug/name/count.
1076 $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'name' ) );
1077 $result = array();
1078 if ( ! is_wp_error( $terms ) ) {
1079 foreach ( $terms as $term ) {
1080 $result[] = array(
1081 'slug' => $term->slug,
1082 'name' => $term->name,
1083 'count' => $term->count,
1084 );
1085 }
1086 }
1087 wp_send_json_success( $result );
1088 }
1089
1090 /**
1091 * AJAX (Tools page): delete imported properties matching selected taxonomy
1092 * terms, in batches of 20. Admin-only. Reports progress so the client can
1093 * loop until done; refreshes term counts once the last batch completes.
1094 *
1095 * @return void Emits a JSON success payload {deleted,remaining,total,done}.
1096 */
1097 function mlsimport_delete_properties() {
1098 global $mlsimport;
1099
1100 // CSRF + capability.
1101 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1102
1103 if ( ! current_user_can( 'administrator' ) ) {
1104 wp_send_json_error( 'Unauthorized' );
1105 }
1106
1107 // Selected taxonomy and its chosen term slugs.
1108 $taxonomy = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) );
1109 $terms = array();
1110
1111 // Collect and sanitize the selected term slugs.
1112 if ( isset( $_POST['mlsimport_delete_category_term'] ) && is_array( $_POST['mlsimport_delete_category_term'] ) ) {
1113 foreach ( $_POST['mlsimport_delete_category_term'] as $term ) {
1114 $terms[] = sanitize_text_field( wp_unslash( $term ) );
1115 }
1116 }
1117
1118 // Require a taxonomy.
1119 if ( '' === $taxonomy ) {
1120 wp_send_json_error( esc_html__( 'Please select a taxonomy', 'mlsimport' ) );
1121 }
1122
1123 // Require at least one term.
1124 if ( empty( $terms ) ) {
1125 wp_send_json_error( esc_html__( 'Please select at least one term', 'mlsimport' ) );
1126 }
1127
1128 // Query one page of property IDs matching the term selection.
1129 $post_type = $mlsimport->admin->env_data->get_property_post_type();
1130
1131 $args = array(
1132 'post_type' => $post_type,
1133 'post_status' => 'any',
1134 'posts_per_page' => 20,
1135 'tax_query' => array(
1136 array(
1137 'taxonomy' => $taxonomy,
1138 'field' => 'slug',
1139 'terms' => $terms,
1140 ),
1141 ),
1142 'fields' => 'ids',
1143 );
1144
1145 $prop_selection = new WP_Query( $args );
1146 $deleted = 0;
1147
1148 // Delete each property in this batch via the theme importer's SQL delete.
1149 foreach ( $prop_selection->posts as $delete_id ) {
1150 $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql( $delete_id, ' delete from tools ' );
1151 ++$deleted;
1152 }
1153
1154 // Compute how many still match after this batch; done when none remain.
1155 $remaining = $prop_selection->found_posts - $deleted;
1156 $done = ( $remaining <= 0 );
1157
1158 // Update term counts only when all deletions are complete
1159 if ( $done ) {
1160 // Recount every taxonomy on the property post type in one pass.
1161 $all_taxonomies = get_object_taxonomies( $post_type );
1162 foreach ( $all_taxonomies as $tax_name ) {
1163 $all_terms = get_terms( array( 'taxonomy' => $tax_name, 'hide_empty' => false, 'fields' => 'ids' ) );
1164 if ( ! is_wp_error( $all_terms ) && ! empty( $all_terms ) ) {
1165 wp_update_term_count_now( $all_terms, $tax_name );
1166 }
1167 }
1168 }
1169
1170 // Report progress back to the client loop.
1171 wp_send_json_success( array(
1172 'deleted' => $deleted,
1173 'remaining' => max( 0, $remaining ),
1174 'total' => $prop_selection->found_posts,
1175 'done' => $done,
1176 ) );
1177 }
1178
1179
1180
1181
1182
1183
1184
1185
1186 /**
1187 * Convert a PHP shorthand byte value (e.g. "256M", "1G", "-1") to bytes.
1188 *
1189 * @param string|int $value Raw ini/constant value.
1190 * @return int Bytes, or -1 for an unlimited (-1) setting.
1191 */
1192 private function mlsimport_parse_bytes( $value ) {
1193 $value = trim( (string) $value );
1194 if ( '' === $value ) {
1195 return 0;
1196 }
1197 if ( '-1' === $value ) {
1198 return -1; // Unlimited.
1199 }
1200 $unit = strtolower( substr( $value, -1 ) );
1201 $number = (int) $value;
1202 switch ( $unit ) {
1203 case 'g':
1204 $number *= 1024 * 1024 * 1024;
1205 break;
1206 case 'm':
1207 $number *= 1024 * 1024;
1208 break;
1209 case 'k':
1210 $number *= 1024;
1211 break;
1212 }
1213 return $number;
1214 }
1215
1216 /**
1217 * Print admin warnings when the PHP/WordPress environment is too constrained
1218 * for large imports (effective memory below 256MB, or a positive
1219 * max_execution_time below 600s). Suppressed during AJAX and on the
1220 * onboarding screen.
1221 *
1222 * Memory is judged from the effective runtime limit: the larger of
1223 * WP_MEMORY_LIMIT (wp-config) and the actual PHP ini memory_limit
1224 * (which may be raised at the server/php.ini level), and -1 counts as
1225 * unlimited. This avoids a false warning when memory is fine but only set
1226 * outside wp-config.php.
1227 */
1228 public function mlsimport_saas_setting_up() {
1229 // Do not output warnings during AJAX requests
1230 if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) ||
1231 ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
1232 return;
1233 }
1234
1235 // Skip all warnings on the onboarding wizard.
1236 $is_onboarding = isset( $_GET['page'] ) && 'mlsimport-onboarding' === $_GET['page'];
1237 if ( $is_onboarding ) {
1238 return;
1239 }
1240
1241 // Effective memory limit: the larger of wp-config's WP_MEMORY_LIMIT and
1242 // the actual PHP runtime limit; either being -1 means unlimited.
1243 $min_bytes = 256 * 1024 * 1024;
1244 $wp_bytes = $this->mlsimport_parse_bytes( WP_MEMORY_LIMIT );
1245 $php_bytes = $this->mlsimport_parse_bytes( ini_get( 'memory_limit' ) );
1246 $memory_ok = ( -1 === $wp_bytes ) || ( -1 === $php_bytes )
1247 || ( $wp_bytes >= $min_bytes ) || ( $php_bytes >= $min_bytes );
1248
1249 // Memory-limit warning.
1250 if ( ! $memory_ok ) { ?>
1251 <div class="mlsimport_warning long_warning">
1252 <?php
1253 printf(
1254 /* translators: 1: current WordPress memory limit, 2: URL to the WordPress documentation on increasing memory. */
1255 wp_kses(
1256 __( '<strong>WordPress Memory Limit</strong> is set to <strong>%1$s</strong>. Allocated Memory should be at least <strong>256MB</strong>. Please refer to: <a href="%2$s" target="_blank">Increasing memory allocated to PHP</a>', 'mlsimport' ),
1257 array(
1258 'strong' => array(),
1259 'a' => array(
1260 'href' => array(),
1261 'target' => array(),
1262 ),
1263 )
1264 ),
1265 esc_html( WP_MEMORY_LIMIT ),
1266 'https://wordpress.org/support/article/editing-wp-config-php/#increasing-memory-allocated-to-php'
1267 );
1268 ?>
1269 </div>
1270 <?php
1271 }
1272
1273 // Execution-time warning: 0 or -1 means unlimited (fine); only a
1274 // positive value below 600s is flagged.
1275 $max_time = (int) ini_get( 'max_execution_time' );
1276 if ( $max_time > 0 && $max_time < 600 ) {
1277 ?>
1278 <div class="mlsimport_warning long_warning">
1279 <?php
1280 printf(
1281 /* translators: %s: current max_execution_time value. */
1282 wp_kses(
1283 __( 'Your <strong>max_execution_time</strong> setting in php is set to <strong>%s</strong>. Importing hundreds of listings requires extra time. Please set max_execution_time to <strong>0 (unlimited)</strong>. If that is not possible, set it to a minimum of <strong>600 (10 minutes)</strong>.', 'mlsimport' ),
1284 array( 'strong' => array() )
1285 ),
1286 esc_html( $max_time )
1287 );
1288 ?>
1289 </div>
1290
1291 <?php
1292 }
1293 }
1294
1295 /**
1296 * Test the configured MLS credentials against the SaaS API.
1297 *
1298 * Gathers every provider's stored credentials, short-circuits (returns
1299 * early) when the mandatory credentials for the selected provider are
1300 * blank, PATCHes them to the 'clients' endpoint, and stores/clears the
1301 * 'mlsimport_connection_test' flag based on whether the API reports the
1302 * connection tested successfully.
1303 *
1304 * @since 4.0.1
1305 * @return array|void The API response, or void on an early return.
1306 */
1307 public function mlsimport_saas_check_mls_connection() {
1308
1309 // Load saved options; $values will accumulate the credentials to send.
1310 $values = array();
1311 $options = get_option( $this->plugin_name . '_admin_options' );
1312
1313 // --- Selected MLS id + generic token ---
1314 $mls_id = '';
1315 if ( isset( $options['mlsimport_mls_name'] ) ) {
1316 $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
1317 }
1318
1319 $mls_token = '';
1320 if ( isset( $options['mlsimport_mls_name'] ) ) {
1321 $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
1322 }
1323
1324 // Numeric MLS id drives the provider-family branching below.
1325 $mls_id_int = intval( $mls_id );
1326
1327 // --- Trestle credentials ---
1328 $mlsimport_tresle_client_id = '';
1329 if ( isset( $options['mlsimport_tresle_client_id'] ) ) {
1330 $mlsimport_tresle_client_id = sanitize_text_field( trim( $options['mlsimport_tresle_client_id'] ) );
1331 }
1332
1333 $mlsimport_tresle_client_secret = '';
1334 if ( isset( $options['mlsimport_tresle_client_secret'] ) ) {
1335 $mlsimport_tresle_client_secret = sanitize_text_field( trim( $options['mlsimport_tresle_client_secret'] ) );
1336 }
1337
1338 // --- ConnectMLS credentials ---
1339 $mlsimport_connectmls_username = '';
1340 if ( isset( $options['mlsimport_connectmls_username'] ) ) {
1341 $mlsimport_connectmls_username = sanitize_text_field( trim( $options['mlsimport_connectmls_username'] ) );
1342 }
1343
1344 $mlsimport_connectmls_password = '';
1345 if ( isset( $options['mlsimport_connectmls_password'] ) ) {
1346 $mlsimport_connectmls_password = sanitize_text_field( trim( $options['mlsimport_connectmls_password'] ) );
1347 }
1348
1349 // rapattoni data
1350 $mlsimport_rapattoni_client_id = '';
1351 if ( isset( $options['mlsimport_rapattoni_client_id'] ) ) {
1352 $mlsimport_rapattoni_client_id = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_id'] ) );
1353 }
1354 $mlsimport_rapattoni_client_secret = '';
1355 if ( isset( $options['mlsimport_rapattoni_client_secret'] ) ) {
1356 $mlsimport_rapattoni_client_secret = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_secret'] ) );
1357 }
1358
1359 $mlsimport_rapattoni_username = '';
1360 if ( isset( $options['mlsimport_rapattoni_username'] ) ) {
1361 $mlsimport_rapattoni_username = sanitize_text_field( trim( $options['mlsimport_rapattoni_username'] ) );
1362 }
1363
1364 $mlsimport_rapattoni_password = '';
1365 if ( isset( $options['mlsimport_rapattoni_password'] ) ) {
1366 $mlsimport_rapattoni_password = sanitize_text_field( trim( $options['mlsimport_rapattoni_password'] ) );
1367 }
1368
1369 // paragon data
1370 $mlsimport_paragon_client_id = '';
1371 if ( isset( $options['mlsimport_paragon_client_id'] ) ) {
1372 $mlsimport_paragon_client_id = sanitize_text_field( trim( $options['mlsimport_paragon_client_id'] ) );
1373 }
1374 $mlsimport_paragon_client_secret = '';
1375 if ( isset( $options['mlsimport_paragon_client_secret'] ) ) {
1376 $mlsimport_paragon_client_secret = sanitize_text_field( trim( $options['mlsimport_paragon_client_secret'] ) );
1377 }
1378
1379 // realtor.ca data
1380 $mlsimport_realtorca_client_id = '';
1381 if ( isset( $options['mlsimport_realtorca_client_id'] ) ) {
1382 $mlsimport_realtorca_client_id = sanitize_text_field( trim( $options['mlsimport_realtorca_client_id'] ) );
1383 }
1384 $mlsimport_realtorca_client_secret = '';
1385 if ( isset( $options['mlsimport_realtorca_client_secret'] ) ) {
1386 $mlsimport_realtorca_client_secret = sanitize_text_field( trim( $options['mlsimport_realtorca_client_secret'] ) );
1387 }
1388
1389 // brightmls data
1390 $mlsimport_brightmls_client_id = '';
1391 if ( isset( $options['mlsimport_brightmls_client_id'] ) ) {
1392 $mlsimport_brightmls_client_id = sanitize_text_field( trim( $options['mlsimport_brightmls_client_id'] ) );
1393 }
1394 $mlsimport_brightmls_client_secret = '';
1395 if ( isset( $options['mlsimport_brightmls_client_secret'] ) ) {
1396 $mlsimport_brightmls_client_secret = sanitize_text_field( trim( $options['mlsimport_brightmls_client_secret'] ) );
1397 }
1398
1399
1400
1401
1402
1403
1404
1405 // When the generic token is blank, the selected provider's own
1406 // credentials are mandatory: bail (no test) if any are missing.
1407 // Each branch maps an mls_id range to one provider family.
1408 if ( trim( $mls_token ) === '' ) {
1409 if ( $this->mlsimport_is_brightmls_provider( $mls_id_int ) ) { // BrightMLS
1410 if ( trim( $mlsimport_brightmls_client_id ) === '' || trim( $mlsimport_brightmls_client_secret ) === '' ) {
1411 return;
1412 }
1413 } elseif ( $mls_id_int > 900 && $mls_id_int < 3000 ) { // Trestle
1414 if ( trim( $mlsimport_tresle_client_id ) === '' || trim( $mlsimport_tresle_client_secret ) === '' ) {
1415 return;
1416 }
1417 } elseif ( $this->mlsimport_is_connectmls_provider( $mls_id_int ) ) { // ConnectMLS
1418 if (
1419 trim( $mlsimport_connectmls_username ) === '' ||
1420 trim( $mlsimport_connectmls_password ) === ''
1421 ) {
1422 return;
1423 }
1424 } elseif ( $mls_id_int >= 5000 && $mls_id_int < 6000 ) { // Rapattoni
1425 if (
1426 trim( $mlsimport_rapattoni_client_id ) === '' ||
1427 trim( $mlsimport_rapattoni_client_secret ) === '' ||
1428 trim( $mlsimport_rapattoni_username ) === '' ||
1429 trim( $mlsimport_rapattoni_password ) === ''
1430 ) {
1431 return;
1432 }
1433 } elseif ( $mls_id_int >= 6000 && $mls_id_int < 7000 ) { // Paragon
1434 if (
1435 trim( $mlsimport_paragon_client_id ) === '' ||
1436 trim( $mlsimport_paragon_client_secret ) === ''
1437 ) {
1438 return;
1439 }
1440 } elseif ( $mls_id_int >= 7000 && $mls_id_int < 8000 ) { // Realtor.ca
1441 if (
1442 trim( $mlsimport_realtorca_client_id ) === '' ||
1443 trim( $mlsimport_realtorca_client_secret ) === ''
1444 ) {
1445 return;
1446 }
1447 } elseif ( mlsimport_is_proptx_provider( $mls_id_int ) ) { // PropTx / AMPRE - static bearer token is the only credential
1448 return;
1449 }
1450 }
1451
1452 // Assemble the full credential payload for every provider.
1453 $values['mls_token'] = $mls_token;
1454 $values['mls_id'] = $mls_id;
1455 $values['mlsimport_tresle_client_id'] = $mlsimport_tresle_client_id;
1456 $values['mlsimport_tresle_client_secret'] = $mlsimport_tresle_client_secret;
1457 $values['mlsimport_connectmls_username'] = $mlsimport_connectmls_username;
1458 $values['mlsimport_connectmls_password'] = $mlsimport_connectmls_password;
1459
1460 $values['mlsimport_rapattoni_client_id'] = $mlsimport_rapattoni_client_id;
1461 $values['mlsimport_rapattoni_client_secret'] = $mlsimport_rapattoni_client_secret;
1462 $values['mlsimport_rapattoni_username'] = $mlsimport_rapattoni_username;
1463 $values['mlsimport_rapattoni_password'] = $mlsimport_rapattoni_password;
1464
1465 $values['mlsimport_paragon_client_id'] = $mlsimport_paragon_client_id;
1466 $values['mlsimport_paragon_client_secret'] = $mlsimport_paragon_client_secret;
1467
1468
1469 $values['mlsimport_realtorca_client_id'] = $mlsimport_realtorca_client_id;
1470 $values['mlsimport_realtorca_client_secret'] = $mlsimport_realtorca_client_secret;
1471
1472 $values['mlsimport_brightmls_client_id'] = $mlsimport_brightmls_client_id;
1473 $values['mlsimport_brightmls_client_secret'] = $mlsimport_brightmls_client_secret;
1474
1475
1476
1477
1478
1479
1480
1481 // PATCH the credentials to the SaaS 'clients' endpoint, which validates
1482 // them against the live MLS and reports back whether it "tested".
1483 $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
1484
1485
1486
1487
1488 // Persist the connection-test flag only on a confirmed successful test;
1489 // any other outcome clears it (and the metadata flag) so the UI re-tests.
1490 if ( isset( $answer['success'] ) && true === $answer['success'] ) {
1491 if ( isset( $answer['tested'] ) && true === $answer['tested'] ) {
1492 update_option( 'mlsimport_connection_test', 'yes' );
1493 mlsimport_telemetry_set_once( 'mls_connected_at', time() );
1494 } else {
1495 delete_option( 'mlsimport_connection_test' );
1496 delete_option( 'mlsimport_mls_metadata_populated' );
1497 }
1498 } else {
1499 delete_option( 'mlsimport_connection_test' );
1500 delete_option( 'mlsimport_mls_metadata_populated' );
1501 }
1502
1503 return $answer;
1504 }
1505
1506 /**
1507 * Whether an MLS id belongs to the ConnectMLS family (8000-8999, but not
1508 * 8001 which is BrightMLS).
1509 *
1510 * @param int $mls_id_int Numeric MLS id.
1511 * @return bool
1512 */
1513 private function mlsimport_is_connectmls_provider( $mls_id_int ) {
1514 return $mls_id_int >= 8000 && $mls_id_int < 9000 && 8001 !== (int) $mls_id_int;
1515 }
1516
1517 /**
1518 * Whether an MLS id is BrightMLS (the single reserved id 8001).
1519 *
1520 * @param int $mls_id_int Numeric MLS id.
1521 * @return bool
1522 */
1523 private function mlsimport_is_brightmls_provider( int $mls_id_int ): bool {
1524 return 8001 === $mls_id_int;
1525 }
1526
1527 /**
1528 * AJAX handler for the plugin-deactivation exit survey.
1529 *
1530 * Thin wrapper: it verifies the nonce and capability, sanitizes input,
1531 * delegates the real work to mlsimport_exit_survey_record(), and POSTs
1532 * the result to the SaaS API. The POST is fire-and-forget — a failed or
1533 * not-yet-deployed endpoint must never stop the admin from deactivating.
1534 */
1535 public function mlsimport_exit_survey_submit() {
1536 check_ajax_referer( 'mlsimport_exit_survey', 'security' );
1537 if ( ! current_user_can( 'administrator' ) ) {
1538 wp_send_json_error( 'Unauthorized' );
1539 }
1540
1541 $input = array(
1542 'reason' => sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) ),
1543 'details' => sanitize_textarea_field( wp_unslash( $_POST['details'] ?? '' ) ),
1544 );
1545
1546 // Only record a recognized reason; an unknown value is dropped
1547 // silently rather than blocking the user or storing junk.
1548 if ( $this->mlsimport_exit_survey_is_valid_reason( $input['reason'] ) ) {
1549 $payload = $this->mlsimport_exit_survey_record( $input );
1550 try {
1551 ThemeImport::globalApiRequestSaas( 'user-activity', $payload, 'POST' );
1552 } catch ( \Throwable $e ) {
1553 // Swallow: deactivation proceeds regardless of transport failure.
1554 }
1555 }
1556
1557 wp_send_json_success();
1558 }
1559
1560 /**
1561 * Whether a submitted exit-survey reason is one of the known options.
1562 *
1563 * Pure predicate — no WordPress functions, no translation.
1564 */
1565 private function mlsimport_exit_survey_is_valid_reason( string $reason ): bool {
1566 return in_array( $reason, $this->mlsimport_exit_survey_reasons(), true );
1567 }
1568
1569 /**
1570 * Whether the current admin request is the Import Task (mlsimport_item)
1571 * post edit screen. Used to scope the Select2 asset enqueue so the
1572 * searchable-select library does not load across all of wp-admin.
1573 *
1574 * @param string $hook_suffix Current admin page hook suffix.
1575 * @param string $post_type Post type of the screen being rendered.
1576 */
1577 private function mlsimport_is_import_task_edit_screen( string $hook_suffix, string $post_type ): bool {
1578 return 'mlsimport_item' === $post_type
1579 && in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true );
1580 }
1581
1582 /**
1583 * Extra CSS class for an Import Task select field. City and County lists
1584 * can hold 300+ entries, so they are upgraded to a searchable multi-select
1585 * (Select2) via this marker class; every other field keeps the plain select.
1586 *
1587 * @param string $field_key Field key from the $field_import definition.
1588 * @return string Leading-space class string, or '' when not searchable.
1589 */
1590 private function mlsimport_searchable_select_class( string $field_key ): string {
1591 return in_array( $field_key, array( 'City', 'CountyOrParish' ), true )
1592 ? ' mlsimport-searchable-select'
1593 : '';
1594 }
1595
1596 /**
1597 * Exit-survey testable core: resolve identity and count, build payload.
1598 *
1599 * Calls no dying functions — the AJAX wrapper handles nonce/capability
1600 * and wp_send_json_*. Always returns the payload to transmit.
1601 *
1602 * @param array $input Sanitized survey input (reason, details).
1603 */
1604 private function mlsimport_exit_survey_record( array $input ): array {
1605 $opts = get_option( 'mlsimport_admin_options', array() );
1606 if ( empty( $opts['mlsimport_install_uuid'] ) ) {
1607 $opts['mlsimport_install_uuid'] = wp_generate_uuid4();
1608 update_option( 'mlsimport_admin_options', $opts );
1609 }
1610
1611 $count = (int) get_option( 'mlsimport_deactivation_count', 0 ) + 1;
1612 update_option( 'mlsimport_deactivation_count', $count );
1613
1614 $reason = (string) ( $input['reason'] ?? '' );
1615 $options = $this->get_exit_survey_options();
1616
1617 return array(
1618 'event_type' => 'exit_survey',
1619 'reason' => $reason,
1620 'reason_label' => $options[ $reason ] ?? '',
1621 'details' => (string) ( $input['details'] ?? '' ),
1622 'account' => (string) ( $opts['mlsimport_username'] ?? '' ),
1623 'install_uuid' => $opts['mlsimport_install_uuid'],
1624 'deactivation_count' => $count,
1625 'environment' => wp_get_environment_type(),
1626 'site_url' => home_url(),
1627 'admin_email' => (string) get_option( 'admin_email' ),
1628 'timestamp' => time(),
1629 );
1630 }
1631
1632 /**
1633 * The known exit-survey reason keys — the single source of truth.
1634 *
1635 * Pure: keys only, no labels, no translation. The label map
1636 * (get_exit_survey_options) builds on top of this for the modal.
1637 *
1638 * @return string[]
1639 */
1640 private function mlsimport_exit_survey_reasons(): array {
1641 return array(
1642 'built_website',
1643 'no_leads',
1644 'technical_issues',
1645 'too_expensive',
1646 'switched_tool',
1647 'other',
1648 );
1649 }
1650
1651 /**
1652 * Exit-survey reason key => display label, for the modal and payload.
1653 *
1654 * Uses translation, so it is not exercised by the pure unit suite —
1655 * is_valid_reason() relies on mlsimport_exit_survey_reasons() instead.
1656 *
1657 * @return array<string,string>
1658 */
1659 private function get_exit_survey_options(): array {
1660 return array(
1661 'built_website' => esc_html__( "Built the website, don't need ongoing sync", 'mlsimport' ),
1662 'no_leads' => esc_html__( 'Not getting leads from my site', 'mlsimport' ),
1663 'technical_issues' => esc_html__( "Technical issues I couldn't fix", 'mlsimport' ),
1664 'too_expensive' => esc_html__( 'Too expensive', 'mlsimport' ),
1665 'switched_tool' => esc_html__( 'Switched to another tool', 'mlsimport' ),
1666 'other' => esc_html__( 'Other', 'mlsimport' ),
1667 );
1668 }
1669
1670
1671
1672
1673
1674
1675 /**
1676 * Return a valid SaaS API bearer token, using the cached transient when
1677 * present, otherwise requesting a fresh one and caching it for ~58 minutes.
1678 *
1679 * @since 4.0.1
1680 * @return string|array The token string, or the raw answer/'' on failure.
1681 */
1682 public function mlsimport_saas_get_mls_api_token_from_transient() {
1683
1684 // Prefer the cached token.
1685 $token = get_transient( 'mlsimport_saas_token' );
1686
1687 // Cache miss/empty: request a new token and cache it on success.
1688 if ( false === $token || '' === $token ) {
1689 $token_json_answer = $this->mlsimport_saas_get_mls_api_token();
1690
1691 if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) {
1692 $token = $token_json_answer['token'];
1693
1694 // 3500s < the token's 1h life, leaving headroom before expiry.
1695 set_transient( 'mlsimport_saas_token', $token, 3500 );
1696 }
1697 }
1698
1699 return $token;
1700 }
1701
1702
1703 /**
1704 * Request a fresh SaaS API token using the stored account username/password.
1705 *
1706 * If the selected MLS changed since the last run, all cached token/metadata
1707 * transients and the field-select option are purged first so nothing leaks
1708 * across providers. Returns '' when credentials are missing.
1709 *
1710 * @since 4.0.1
1711 * @return array|string The 'token' API response, or '' when unconfigured.
1712 */
1713 protected function mlsimport_saas_get_mls_api_token() {
1714 $values = array();
1715 $options = get_option( $this->plugin_name . '_admin_options' );
1716
1717 // Check if the MLS provider has changed since the last run
1718 $prev_mls = get_option( 'mlsimport_prev_mls_name', '' );
1719
1720
1721 $username = '';
1722 if ( isset( $options['mlsimport_username'] ) ) {
1723 $username = sanitize_text_field( trim( $options['mlsimport_username'] ) );
1724 }
1725
1726 $password = '';
1727 if ( isset( $options['mlsimport_password'] ) ) {
1728 $password = sanitize_text_field( trim( $options['mlsimport_password'] ) );
1729 }
1730 $mls_name = '';
1731 if ( isset( $options['mlsimport_mls_name'] ) ) {
1732 $mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
1733 }
1734
1735 $mls_token = '';
1736 if ( isset( $options['mlsimport_mls_token'] ) ) {
1737 $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
1738 }
1739
1740 // Provider switch detected: purge all cross-provider cached state.
1741 if ( $prev_mls !== '' && $prev_mls !== $mls_name ) {
1742 delete_transient( 'mlsimport_token_request' );
1743 delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
1744 delete_transient( 'mls_import_meta_enums' );
1745 delete_transient( 'mls_import_meta' );
1746 delete_transient( 'mlsimport_plugin_data_schema' );
1747 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1748 delete_transient( 'mlsimport_saas_token' );
1749
1750 delete_option( 'mlsimport_mls_metadata_populated' );
1751
1752 delete_option( 'mlsimport_admin_fields_select' );
1753 }
1754
1755 // Remember the current MLS so the next call can detect a switch.
1756 update_option( 'mlsimport_prev_mls_name', $mls_name );
1757
1758
1759
1760 // Credentials to exchange for a token.
1761 $values['username'] = $username;
1762 $values['password'] = $password;
1763
1764 // No account credentials -> nothing to request.
1765 if ( '' === $username || '' === $password ) {
1766 return '';
1767 }
1768
1769 // POST to the SaaS 'token' endpoint and return its response.
1770 $theme_Start = new ThemeImport();
1771 $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' );
1772
1773
1774
1775 return $answer;
1776 }
1777
1778
1779
1780
1781
1782
1783 /**
1784 * Register the "Set Import data" metabox on the mlsimport_item post type.
1785 *
1786 * @since 3.0.1
1787 */
1788 public function mlsimport_item_product_metaboxes() {
1789 // The metabox renders the import-parameter form for an Import Task.
1790 add_meta_box( 'mlsimport_item_metaboxes-sectionid', __( 'Set Import data', 'mlsimport' ), array( $this, 'mlsimport_saas_display_meta_options' ), 'mlsimport_item', 'normal', 'default' );
1791 }
1792
1793
1794
1795 /**
1796 * Save the Import Task metabox fields to post meta (save_post callback).
1797 *
1798 * Only acts on mlsimport_item posts. Sanitizes and stores each posted
1799 * whitelisted key; separately, any "blank_keys" absent from the POST (e.g.
1800 * unchecked multi-selects) are explicitly reset to '' so cleared selections
1801 * actually clear.
1802 *
1803 * @param int $post_id Post being saved.
1804 * @param WP_Post $post Post object.
1805 * @since 3.0.1
1806 */
1807 public function mlsimport_item_product_save_metaboxes( $post_id, $post ) {
1808
1809 // Guard against non-post contexts.
1810 if ( ! is_object( $post ) || ! isset( $post->post_type ) ) {
1811 return;
1812 }
1813
1814 // Only handle Import Task posts.
1815 if ( 'mlsimport_item' !== $post->post_type ) {
1816 return;
1817 }
1818
1819 // Never persist metabox fields from autosaves or revision saves.
1820 if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) ) {
1821 return;
1822 }
1823
1824 // The nonce rendered by mlsimport_saas_display_meta_options().
1825 if ( ! isset( $_POST['estate_agent_noncename'] )
1826 || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['estate_agent_noncename'] ) ), plugin_basename( __FILE__ ) ) ) {
1827 return;
1828 }
1829
1830 // Import Tasks are admin-only: require edit rights on this task.
1831 if ( ! current_user_can( 'edit_post', $post_id ) ) {
1832 return;
1833 }
1834
1835 // Every import-parameter meta key this metabox may write.
1836 $allowed_keys = array(
1837 'mlsimport_item_how_many',
1838 'mlsimport_item_title_format',
1839 'mlsimport_item_agent',
1840 'mlsimport_item_use_mls_agent',
1841 'mlsimport_item_property_status',
1842 'mlsimport_item_property_user',
1843 'mlsimport_item_min_price',
1844 'mlsimport_item_max_price',
1845 'mlsimport_item_city_check',
1846 'mlsimport_item_city',
1847 'mlsimport_item_city[]',
1848 'mlsimport_item_countyorparish_check',
1849 'mlsimport_item_countyorparish',
1850 'mlsimport_item_mlsstatus_check',
1851 'mlsimport_item_mlsstatus',
1852 'mlsimport_item_propertysubtype_check',
1853 'mlsimport_item_propertysubtype',
1854 'mlsimport_item_propertytype_check',
1855 'mlsimport_item_propertytype',
1856 'mlsimport_item_standardstatus_check',
1857 'mlsimport_item_standardstatus',
1858 'mlsimport_item_standardstatusprotect_check',
1859 'mlsimport_item_standardstatusprotect',
1860
1861 'mlsimport_item_internetentirelistingdisplayyn',
1862 'mlsimport_item_internetaddressdisplayyn',
1863 'mlsimport_item_stat_cron',
1864 'mlsimport_item_listagentkey',
1865 'mlsimport_item_listagentmlsid',
1866 'mlsimport_item_buyeragentmlsid',
1867 'mlsimport_item_listofficekey',
1868 'mlsimport_item_postalcode',
1869 'mlsimport_item_listofficemlsid',
1870 'mlsimport_item_listingid',
1871 'mlsimport_item_extracity',
1872 'mlsimport_item_extracounty',
1873 'mlsimport_item_exclude_listofficemlsid',
1874 'mlsimport_item_exclude_listofficekey',
1875 'mlsimport_item_exclude_listagentmlsid',
1876 'mlsimport_item_exclude_listagentkey',
1877 'mlsimport_item_customparameters',
1878 'mlsimport_item_mlsareamajor',
1879 'mlsimport_item_subdivisionname',
1880 );
1881
1882
1883
1884
1885 // Store each posted key (recursively sanitized; key sanitized too).
1886 foreach ( $allowed_keys as $key => $key_value ) {
1887 if( isset($_POST[$key_value]) ){
1888 $postmeta = mlsimport_sanitize_multi_dimensional_array ( $_POST[$key_value] ) ;
1889 update_post_meta( $post_id, sanitize_key( $key_value ), $postmeta );
1890 }
1891
1892 }
1893
1894 // Keys that must be reset to '' when omitted from the POST (cleared).
1895 $blank_keys = array(
1896 'mlsimport_item_use_mls_agent',
1897 'mlsimport_item_standardstatus',
1898 'mlsimport_item_standardstatusprotect',
1899 'mlsimport_item_city',
1900 'mlsimport_item_countyorparish',
1901 'mlsimport_item_propertysubtype',
1902 'mlsimport_item_propertytype',
1903 'mlsimport_item_standardstatus',
1904 'mlsimport_item_listingid',
1905 'mlsimport_item_customparameters',
1906 'mlsimport_item_mlsareamajor',
1907 'mlsimport_item_subdivisionname',
1908
1909 );
1910
1911 // Reset any whitelisted-blank key that was not submitted this save.
1912 foreach ( $blank_keys as $key ) {
1913 if ( ! isset( $_POST[ $key ] ) ) {
1914 update_post_meta( $post_id, $key, '' );
1915 }
1916 }
1917
1918
1919 }
1920
1921
1922 /**
1923 * Render the Import Task metabox content.
1924 *
1925 * Ensures a live SaaS token + MLS connection, prints a warning and stops if
1926 * either is missing, otherwise runs a listing count request and hands off to
1927 * generateMetaOptionsHtml() to build the parameter form.
1928 *
1929 * @param WP_Post $post The post object.
1930 */
1931 public function mlsimport_saas_display_meta_options($post) {
1932 // Nonce for the metabox save.
1933 wp_nonce_field(plugin_basename(__FILE__), 'estate_agent_noncename');
1934 global $mlsimport;
1935
1936 // Ensure a token, read the cached connection flag, print env warnings.
1937 $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
1938 $is_mls_connected = get_option('mlsimport_connection_test', '');
1939 $mlsimport->admin->mlsimport_saas_setting_up();
1940
1941 // If not marked connected, run the connection test once and re-read the flag.
1942 if ('yes' !== $is_mls_connected) {
1943 $mlsimport->admin->mlsimport_saas_check_mls_connection();
1944 $is_mls_connected = get_option('mlsimport_connection_test', '');
1945 }
1946
1947 // No token -> account not authenticated; stop with a notice.
1948 if (trim($token) === '') {
1949 echo '<div class="mlsimport_warning">' . esc_html__('You are not connected to MlsImport - Please check your Username and Password.', 'mlsimport') . '</div>';
1950 return;
1951 }
1952
1953 // Token OK but MLS connection failed -> stop with a notice.
1954 if ('yes' !== $is_mls_connected) {
1955 echo '<div class="mlsimport_warning">' . esc_html__('The connection to your MLS was NOT succesful. Please check the authentication token is correct and check your MLS Data Access Application is approved.', 'mlsimport') . '</div>';
1956 return;
1957 }
1958
1959 // Load current task settings for the form.
1960 $postId = $post->ID;
1961 $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1962 $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1963 $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1964 $status = get_option('mlsimport_force_stop_' . $postId);
1965 $fieldImport = $this->mlsimport_saas_return_mls_fields();
1966 $options = get_option('mlsimport_admin_options');
1967 $mlsimportMlsId = isset($options['mlsimport_mls_name']) && $options['mlsimport_mls_name'] !== ''
1968
1969 ? intval($options['mlsimport_mls_name'])
1970 : 0;
1971
1972 // Ask the MLS how many listings currently match this task.
1973 $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1974 // print_r($mlsRequest);
1975
1976 // Surface any API error message inline.
1977 $hasError = isset($mlsRequest['success']) && !$mlsRequest['success'];
1978 if ($hasError) {
1979 echo '<div class="mlsimport_warning">' . esc_html($mlsRequest['message']) . '</div>';
1980 }
1981
1982 // 'none' means no results key -> likely an expired token; re-test.
1983 $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none';
1984 if ($foundItems === 'none') {
1985 $mlsimport->admin->mlsimport_saas_check_mls_connection();
1986 esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport');
1987 }
1988
1989 // Build and print the parameter form.
1990 echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError);
1991 }
1992
1993
1994
1995
1996 /**
1997 * Generate Meta Options HTML
1998 *
1999 * @param int $postId The post ID.
2000 * @param int $foundItems The number of found items.
2001 * @param string $lastDate The last date checked.
2002 * @param string $mlsimportItemHowMany How many items to import.
2003 * @param string $mlsimportItemStatCron The status of the cron job.
2004 * @param int $mlsimportMlsId The MLS import ID.
2005 * @param array $fieldImport The fields to import.
2006 * @return string The generated HTML.
2007 */
2008 private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError = false) {
2009
2010
2011 // Buffer all HTML and return it as a string.
2012 ob_start();
2013
2014 // Decode the saved MLS enums so City/County/PropertyType options can
2015 // carry their human-readable labels alongside the raw values.
2016 $metadata_api_call_city = array();
2017 $metadata_api_call_county = array();
2018 $metadata_api_call_property_type = array();
2019 $mlsimport_mls_metadata_mls_enums = get_option('mlsimport_mls_metadata_mls_enums', '');
2020 if ('' !== $mlsimport_mls_metadata_mls_enums) {
2021 $metadata_api_call_full = json_decode($mlsimport_mls_metadata_mls_enums, true);
2022 if (isset($metadata_api_call_full['global_array']['PropertyEnums'])) {
2023 $property_enums = $metadata_api_call_full['global_array']['PropertyEnums'];
2024 if (isset($property_enums['City']) && is_array($property_enums['City'])) {
2025 $metadata_api_call_city = $property_enums['City'];
2026 }
2027
2028 if (isset($property_enums['CountyOrParish']) && is_array($property_enums['CountyOrParish'])) {
2029 $metadata_api_call_county = $property_enums['CountyOrParish'];
2030 }
2031
2032 if (isset($property_enums['PropertyType']) && is_array($property_enums['PropertyType'])) {
2033 $metadata_api_call_property_type = $property_enums['PropertyType'];
2034 }
2035 }
2036 }
2037
2038 ?>
2039 <div class="mlsimport_item_search_url" style="display:none;"><?php echo esc_html__('Last date/time we check :', 'mlsimport') . ' ' . esc_html($lastDate); ?></div>
2040 <ul>
2041 <li>1. Set the import parameters.</li>
2042 <li>2. Hit Publish or Update, otherwise import will not work correctly.</li>
2043 <li>3. Click the Start Import button. Most MLS limit the import number to 1000. If you need to import more create additional import items.</li>
2044 <li>4. Press the Update button after you make any change in the import settings.</li>
2045 </ul>
2046
2047 <?php if (is_numeric($foundItems) && $foundItems >= 500): ?>
2048 <div class="mlsimport_notification">
2049 <?php esc_html_e('You found a large number of listings. While MlsImport import can handle such a large number, you need to make sure that your server can do this operation. This import will take some time. Make sure your server has the capacity, there are no time limits for a long-running process and consider splitting the import between multiple MLS Import Tasks.', 'mlsimport'); ?>
2050 </div>
2051 <?php endif; ?>
2052
2053 <div class="mlsimport_import_no">
2054 <?php esc_html_e('We found', 'mlsimport'); ?>
2055 <strong><?php echo esc_html($foundItems); ?></strong> listings. If you decide to import all of them make sure your server database can handle the load. Please do a database backup before initial import.
2056 </div>
2057
2058 <fieldset class="mlsimport-fieldset">
2059 <label class="mlsimport-label" for="mlsimport_item_how_many">
2060 <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?>
2061 </label>
2062 <input type="text" id="mlsimport_item_how_many" name="mlsimport_item_how_many"
2063 class="mlsimport-input mlsimport-2025-input " value="<?php echo esc_attr($mlsimportItemHowMany); ?>"/>
2064 </fieldset>
2065
2066 <fieldset class="mlsimport-fieldset mlsimport_auto_switch">
2067 <?php esc_html_e('Enable Auto Update every hour?', 'mlsimport'); ?>
2068 <label class="mlsimport_switch">
2069 <input type="hidden" value="0" name="mlsimport_item_stat_cron">
2070 <input type="checkbox" class="mlsimport-import-checkbox" value="1" name="mlsimport_item_stat_cron"<?php if (intval($mlsimportItemStatCron) !== 0) echo esc_html(' checked'); ?>>
2071 <span class="slider round"></span>
2072 </label>
2073 </fieldset>
2074
2075 <?php if ($mlsimportItemStatCron !== '' && !$hasError): ?>
2076 <div id="mlsimport_item_status">Ready to import!</div>
2077 <div id="mlsimport_item_progress" class="mlsimport-progress-bar">
2078 <div class="mlsimport-progress-bar-inner" style="width:0%;"></div>
2079 </div>
2080 <input class="button mlsimport_button save_data " type="button" id="mlsimport-start_item"
2081 data-post-number="<?php echo intval($foundItems); ?>"
2082 data-post_id="<?php echo intval($postId); ?>" value="Start Import">
2083 <input class="button mlsimport_button error_action" type="button" id="mlsimport_stop_item"
2084 data-post-number="<?php echo intval($foundItems); ?>"
2085 data-post_id="<?php echo intval($postId); ?>" value="Stop Import">
2086 <?php endif; ?>
2087
2088 <input type="hidden" id="mlsimport_item_actions" value="<?php echo esc_attr(wp_create_nonce("mlsimport_item_actions")); ?>"/>
2089 <div class="mlsimport_param_wrapper"><h2><?php esc_html_e('Import Parameters', 'mlsimport'); ?></h2>
2090
2091 <?php
2092 $mlsimportItemTitleFormat = esc_html(get_post_meta($postId, 'mlsimport_item_title_format', true));
2093 ?>
2094
2095 <fieldset class="mlsimport-fieldset">
2096 <label class="mlsimport-label" for="mlsimport_item_title_format">
2097 <?php esc_html_e('Title Format', 'mlsimport'); ?>
2098 </label>
2099
2100 <p class="mlsimport-exp"><?php esc_html_e('You can use {Address}, {City}, {CountyOrParish}, {StateOrProvince}, {PostalCode}, {PropertyType}, {Bedrooms}, {Bathrooms}, {ListingKey}, {ListingId},{StreetNumberNumeric} or {StreetName}', 'mlsimport'); ?></p>
2101 <input type="text" id="mlsimport_item_title_format" name="mlsimport_item_title_format"
2102 class="mlsimport-input mlsimport-2025-input"
2103 value="<?php echo '' !== $mlsimportItemTitleFormat ? trim(esc_html($mlsimportItemTitleFormat)) : esc_html('{Address},{City},{CountyOrParish},{PropertyType}'); ?>"/>
2104 </fieldset>
2105
2106 <?php
2107 $mlsimportItemAgent = esc_html(get_post_meta($postId, 'mlsimport_item_agent', true));
2108 ?>
2109
2110 <fieldset class="mlsimport-fieldset">
2111 <label class="mlsimport-label" for="mlsimport_item_agent">
2112 <?php esc_html_e('Select Agent', 'mlsimport'); ?>
2113 </label>
2114 <select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_agent" id="mlsimport_item_agent">
2115 <?php
2116 $permitedTags = mlsimport_allowed_html_tags_content();
2117 $selectAgent =$this->theme_importer->mlsimportSaasThemeImportSelectAgent($mlsimportItemAgent);
2118 print wp_kses($selectAgent, $permitedTags);
2119 ?>
2120 </select>
2121 </fieldset>
2122
2123 <?php if ( mlsimport_is_standalone_mode() ) :
2124 $mlsimportItemUseMlsAgent = get_post_meta($postId, 'mlsimport_item_use_mls_agent', true);
2125 ?>
2126 <fieldset class="mlsimport-fieldset">
2127 <label class="mlsimport-label" for="mlsimport_item_use_mls_agent">
2128 <?php esc_html_e('Which agent shows on these properties', 'mlsimport'); ?>
2129 </label>
2130 <p class="mlsimport-exp"><?php esc_html_e('Off: every property from this task shows the agent you picked above. On: each property shows its own listing agent instead — the name, phone, email and office that came with that listing in the MLS feed, and the agent picked above is ignored. No agent profiles are created either way.', 'mlsimport'); ?></p>
2131 <label class="mlsimport-switch">
2132 <input type="checkbox" id="mlsimport_item_use_mls_agent" name="mlsimport_item_use_mls_agent" value="1" <?php checked('1', (string) $mlsimportItemUseMlsAgent); ?> />
2133 <?php esc_html_e('Show each property\'s own listing agent from the MLS feed', 'mlsimport'); ?>
2134 </label>
2135 </fieldset>
2136 <?php endif; ?>
2137
2138 <?php
2139 $mlsimportItemPropertyStatus = esc_html(get_post_meta($postId, 'mlsimport_item_property_status', true));
2140 if ('' === $mlsimportItemPropertyStatus) {
2141 $mlsimportItemPropertyStatus = 'publish';
2142 }
2143 $statusArray = array('publish', 'draft');
2144 ?>
2145 <fieldset class="mlsimport-fieldset">
2146 <label class="mlsimport-label" for="mlsimport_item_property_status">
2147 <?php esc_html_e('Select Property Status on import', 'mlsimport'); ?>
2148 </label>
2149 <select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_property_status" id="mlsimport_item_property_status">
2150 <?php foreach ($statusArray as $value): ?>
2151 <option value="<?php echo esc_attr($value); ?>" <?php if ($value === $mlsimportItemPropertyStatus) echo esc_html('selected'); ?>>
2152 <?php echo esc_html($value); ?>
2153 </option>
2154 <?php endforeach; ?>
2155 </select>
2156 </fieldset>
2157
2158 <?php
2159 $mlsimportItemPropertyUser = esc_html(get_post_meta($postId, 'mlsimport_item_property_user', true));
2160 ?>
2161 <fieldset class="mlsimport-fieldset">
2162 <label class="mlsimport-label" for="mlsimport_item_property_user">
2163 <?php esc_html_e('User', 'mlsimport'); ?>
2164 </label>
2165 <select class="mlsimport-select mlsimport-2025-select" id="mlsimport_item_property_user" name="mlsimport_item_property_user">
2166 <?php
2167 $selectUser = $this->theme_importer->mlsimportSaasThemeImportSelectUser($mlsimportItemPropertyUser);
2168 print wp_kses($selectUser, $permitedTags);
2169 ?>
2170 </select>
2171 </fieldset>
2172
2173 <?php
2174 $mlsimportItemMinPrice = floatval(get_post_meta($postId, 'mlsimport_item_min_price', true));
2175 $mlsimportItemMaxPrice = floatval(get_post_meta($postId, 'mlsimport_item_max_price', true));
2176 if (0 === intval($mlsimportItemMaxPrice)) {
2177 $mlsimportItemMaxPrice = 10000000;
2178 }
2179 ?>
2180 <fieldset class="mlsimport-fieldset">
2181 <label class="mlsimport-label">
2182 <?php esc_html_e('Price Between', 'mlsimport'); ?>
2183 </label>
2184 <input type="text" class="mlsimport-select mlsimport-input mlsimport-2025-input " id="mlsimport_item_min_price" name="mlsimport_item_min_price" value="<?php echo esc_attr($mlsimportItemMinPrice); ?>"> and
2185 <input type="text" class="mlsimport-select mlsimport-input mlsimport-2025-input " id="mlsimport_item_max_price" name="mlsimport_item_max_price" value="<?php echo esc_attr($mlsimportItemMaxPrice); ?>">
2186 </fieldset>
2187
2188 <?php
2189 // Provider-specific tweaks to the field list before rendering.
2190 $options = get_option($this->plugin_name . '_admin_options');
2191
2192 $mlsId = '';
2193 if (isset($options['mlsimport_mls_name'])) {
2194 $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name']));
2195 }
2196
2197 // Rapattoni (5000+): PropertyType becomes single-select.
2198 if ($mlsId > 5000) {
2199 $fieldImport['PropertyType']['multiple'] = 'no';
2200 }
2201
2202
2203 if ($mlsId >= 7000) {
2204 // there is no such thing for realtor.ca
2205 unset($fieldImport['PropertyType']);
2206 }
2207
2208
2209
2210
2211 // Render one fieldset per import parameter.
2212 foreach ($fieldImport as $key => $field):
2213 // Skip fields flagged hidden.
2214 if (!empty($field['hidden'])) {
2215 continue;
2216 }
2217 // Derive the meta key + its companion "_check" (select-all) key.
2218 $nameCheck = strtolower('mlsimport_item_' . $key . '_check');
2219 $name = strtolower('mlsimport_item_' . $key);
2220
2221 // Current saved value + select-all flag for this field.
2222 $value = get_post_meta($postId, $name, true);
2223 $valueCheck = get_post_meta($postId, $nameCheck, true);
2224 // extraCity/extraCounty render as a toggle button, not a plain label.
2225 $extraClass = '';
2226 if ('extraCity' === $key || 'extraCounty' === $key) {
2227 $extraClass = ' mlsimport_hidden_field_button button mlsimport_button';
2228 }
2229 ?>
2230 <fieldset class="mlsimport-fieldset">
2231 <label class="mlsimport-label <?php echo esc_attr($extraClass); ?>" for="<?php echo esc_attr($name); ?>">
2232 <?php echo esc_html($field['label']); ?>
2233 </label>
2234 <?php if ('extraCity' === $key || 'extraCounty' === $key): ?>
2235 <div class="mlsimport-input-wrapper" style="display:none">
2236 <?php endif; ?>
2237 <p class="mlsimport-exp"><?php echo wp_kses_post($this->mlsimport_notes_for_mls($mlsimportMlsId, $name, $field['description'])); ?>
2238 <?php
2239 // Whether the "select all" checkbox is currently on.
2240 $isCheckboxAdmin = 0;
2241 if (1 === intval($valueCheck)) {
2242 $isCheckboxAdmin = 1;
2243 }
2244
2245 // Fields that must NOT offer a "select all" checkbox.
2246 $selectAllNone = [
2247 'InternetAddressDisplayYN',
2248 'InternetEntireListingDisplayYN',
2249 'PostalCode',
2250 'ListAgentKey',
2251 'ListAgentMlsId',
2252 'BuyerAgentMlsId',
2253 'ListOfficeKey',
2254 'ListOfficeMlsId',
2255 'StandardStatus',
2256 'ListingId',
2257 'extraCity',
2258 'extraCounty',
2259 'Exclude_ListOfficeKey',
2260 'Exclude_ListOfficeMlsId',
2261 'Exclude_ListAgentKey',
2262 'Exclude_ListAgentMlsId',
2263 'CustomParameters',
2264 'MLSAreaMajor',
2265 'SubdivisionName',
2266 ];
2267
2268 if ($mlsId > 5000) {
2269 $selectAllNone[] = 'PropertyType';
2270 }
2271
2272 if (!in_array($key, $selectAllNone)): ?>
2273 <?php
2274 esc_html_e('- Or Select All ', 'mlsimport');
2275
2276 ?>
2277 <input type="hidden" name="<?php echo esc_attr($nameCheck); ?>" value="0"/>
2278 <input type="checkbox" class="mlsimport-import-checkbox" name="<?php echo esc_attr($nameCheck); ?>" value="1" <?php print esc_attr(checked($isCheckboxAdmin, 1, 0)); ?>/>
2279 <?php endif; ?>
2280 </p>
2281
2282 <?php
2283 $permittedStatus = ['active', 'active under contract', 'coming soon', 'activeundercontract', 'comingsoon', 'pending'];
2284
2285 if ($field['type'] === 'select'): ?>
2286 <?php
2287 // Multi-select fields need the multiple attr + [] name.
2288 $multiple = '';
2289 if ('yes' === $field['multiple']) {
2290 $multiple = 'multiple';
2291 $name .= '[]';
2292 }
2293
2294 // Default StandardStatus to Active when nothing saved.
2295 if ('StandardStatus' === $key && '' === $value) {
2296 $value = ['Active'];
2297 }
2298
2299
2300
2301 // City/County lists can hold 300+ entries — render a filter
2302 // input above the full native multi-select listbox.
2303 $searchableClass = $this->mlsimport_searchable_select_class($key);
2304 $isSearchable = '' !== $searchableClass;
2305 $searchPlaceholder = $isSearchable
2306 ? esc_html__('Type to search…', 'mlsimport')
2307 : '';
2308
2309 // Additional conditions can be placed here.
2310 ?>
2311 <?php if ($isSearchable): ?>
2312 <div class="mlsimport-selected-chips" aria-live="polite"></div>
2313 <input type="text" class="mlsimport-select-search" placeholder="<?php echo esc_attr($searchPlaceholder); ?>" aria-label="<?php echo esc_attr($searchPlaceholder); ?>" autocomplete="off">
2314 <?php endif; ?>
2315 <select class="mlsimport-select mlsimport-2025-select<?php echo esc_attr($searchableClass); ?>" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>"<?php echo $isSearchable ? ' size="12"' : ''; ?> <?php echo esc_attr($multiple); ?>>
2316 <?php foreach ($field['values'] as $selectKey): ?>
2317
2318 <?php if ('' !== $selectKey): ?>
2319 <?php
2320 // Match saved value against the raw key AND its
2321 // enum-mapped label, so either form stays selected.
2322 $option_value = $selectKey;
2323 $option_label = $selectKey;
2324 $comparison_values = array($option_value);
2325
2326 if ('City' === $key && isset($metadata_api_call_city[$selectKey])) {
2327 $option_label = $selectKey;
2328 $comparison_values[] = $metadata_api_call_city[$selectKey];
2329 } elseif ('CountyOrParish' === $key && isset($metadata_api_call_county[$selectKey])) {
2330 $option_label = $selectKey;
2331 $comparison_values[] = $metadata_api_call_county[$selectKey];
2332 } elseif ('PropertyType' === $key && isset($metadata_api_call_property_type[$selectKey])) {
2333 $option_label = $selectKey;
2334 $comparison_values[] = $metadata_api_call_property_type[$selectKey];
2335 }
2336
2337 $comparison_values = array_values(array_unique(array_filter($comparison_values, static function ($compare_value) {
2338 return '' !== $compare_value && null !== $compare_value;
2339 })));
2340
2341 // Selected if any comparison value matches the saved
2342 // value (array for multi-selects, scalar otherwise).
2343 $is_selected = false;
2344 if (is_array($value)) {
2345 $is_selected = count(array_intersect($comparison_values, $value)) > 0;
2346 } else {
2347 $is_selected = in_array($value, $comparison_values, true);
2348 }
2349 ?>
2350 <option value="<?php echo esc_attr($option_value); ?>" <?php echo $is_selected ? 'selected' : ''; ?>>
2351 <?php echo esc_html($option_label); ?>
2352 </option>
2353 <?php endif; ?>
2354
2355 <?php endforeach; ?>
2356 </select>
2357
2358 <?php elseif ($field['type'] === 'input'): ?>
2359 <input type="text" class="mlsimport-select mlsimport-input mlsimport-2025-input" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>">
2360 <?php endif; ?>
2361 <?php if ('extraCity' === $key || 'extraCounty' === $key): ?>
2362 </div>
2363 <?php endif; ?>
2364 </fieldset>
2365 <?php endforeach; ?>
2366
2367 </div>
2368 <?php
2369 // Return the buffered form markup.
2370 return ob_get_clean();
2371 }
2372
2373
2374
2375
2376
2377
2378 // Placeholder hook target for injecting additional Import Task fields (no-op).
2379 public function mlsimport_add_extra_fields() {
2380 }
2381
2382 /**
2383 * Per-field help text override, keyed by MLS + meta field.
2384 *
2385 * Currently only special-cases MLS 111 (Rae Edmonton), which has no status
2386 * field; every other case returns the field's default description unchanged.
2387 *
2388 * @param int $mlsimport_mls_id Numeric MLS id.
2389 * @param string $name Meta field name (e.g. mlsimport_item_standardstatus).
2390 * @param string $description Default description to fall back to.
2391 * @return string
2392 */
2393 function mlsimport_notes_for_mls( $mlsimport_mls_id, $name, $description ) {
2394 // 111 - Rae Edmonton
2395
2396 if ( 111 === intval($mlsimport_mls_id) && 'mlsimport_item_standardstatus' === $name ) {
2397 return esc_html__( 'Your MLS does not use this field - all listings are considered Active.', 'mlsimport' );
2398 } else {
2399 return $description;
2400 }
2401 }
2402
2403
2404 /**
2405 * Return the "last checked" timestamp for an Import Task, seeding it if unset.
2406 *
2407 * @param int $item_id Import Task post id.
2408 * @return string A 'Y-m-d\TH:i' timestamp.
2409 */
2410 public function mlsimport_saas_get_last_date( $item_id ) {
2411 // Stored watermark used as the modification-time filter for syncs.
2412 $last_date = get_post_meta( $item_id, 'mlsimport_last_date', true );
2413
2414 // First run: initialize it.
2415 if ( '' === $last_date ) {
2416 $last_date = $this->mlsimport_saas_update_last_date( $item_id );
2417 }
2418 return $last_date;
2419 }
2420
2421
2422 /**
2423 * Set the Import Task's "last checked" watermark to 2 hours ago and store it.
2424 *
2425 * The 2-hour backdate provides overlap so listings modified right around the
2426 * run boundary are not missed. Note: also echoes the value as a side effect.
2427 *
2428 * @param int $item_id Import Task post id.
2429 * @return string The stored 'Y-m-d\TH:i' timestamp.
2430 */
2431 public function mlsimport_saas_update_last_date( $item_id ) {
2432
2433 // Current site time minus 2 hours, formatted as an ISO-ish local stamp.
2434 $unix_time = current_time( 'timestamp', 0 ) - ( 2 * 60 * 60 );
2435 print $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time );
2436 update_post_meta( $item_id, 'mlsimport_last_date', $last_date_to_save );
2437
2438 return $last_date_to_save;
2439 }
2440
2441
2442
2443
2444
2445 /**
2446 * Check and process MLSimport item for modified listings in the last 2 hours.
2447 * Optimized for memory: logs memory, unsets large arrays, and triggers garbage collection.
2448 *
2449 * @param int $item_id
2450 * @return int Number of listings found in the MLS feed, or 0 on failure.
2451 */
2452 public function mlsimport_saas_start_cron_links_per_item( int $item_id ): int {
2453 // Log memory before start
2454
2455 $found_items = 0;
2456
2457 // The cron only runs on a task that has fully completed a previous
2458 // import. That excludes a task never imported by hand (no
2459 // 'mlsimport_spawn_status' meta) AND a task with a manual import in
2460 // flight ('started'): starting a second loop there would make both
2461 // runs write the same task/progress meta and corrupt each other.
2462 if ( ! mlsimport_cron_should_process_task( get_post_meta( $item_id, 'mlsimport_spawn_status', true ) ) ) {
2463 return 0;
2464 }
2465
2466 // Only pull listings modified since the task's watermark.
2467 $last_date = $this->mlsimport_saas_get_last_date( $item_id );
2468 print 'MLSitem id: ' . $item_id . ' - ';
2469 esc_html_e('date to consider: ','mlsimport');
2470 print esc_html($last_date) . '. ';
2471
2472 // Make request to MLS API
2473 $mlsrequest = $this->mlsimport_make_listing_requests( $item_id, $last_date, '', '', true );
2474
2475 // Success: record the count. Failure: drop the token (force refresh
2476 // next run) and record the failure telemetry.
2477 if ( isset( $mlsrequest['results'] ) ) {
2478 $found_items = intval( $mlsrequest['results'] );
2479 } else {
2480 delete_transient( 'mlsimport_saas_token' );
2481 mlsimport_telemetry_set( 'last_sync_failed', time() );
2482 mlsimport_telemetry_set( 'last_sync_failed_code', (string) ( $mlsrequest['error_code'] ?? 'unknown' ) );
2483 }
2484 print esc_html__('We found ','mlsimport') . esc_html( $found_items ) . ' listings.</br>' . PHP_EOL;
2485
2486 // Only process if items found
2487 if ( $found_items > 0 ) {
2488
2489 $item_id_array = array(
2490 'item_id' => $item_id,
2491 'how_many' => 0,
2492 'max_number' => $found_items,
2493 'batch_counter' => 1,
2494 );
2495
2496 // Build the paginated batch of request-argument sets to run.
2497 // Potentially large array, log memory before/after
2498 $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date, true );
2499
2500 // Persist the batch + mark the cron job started.
2501 // Store in post meta (beware if array is huge)
2502 update_post_meta( $item_id, 'mlsimport_spawn_status_cron_job', 'started' );
2503 update_post_meta( $item_id, 'mlsimport_cron_attach_to_move_' . $item_id, $attachments_to_move );
2504
2505 // Save last date for next run
2506 $this->mlsimport_saas_update_last_date( $item_id );
2507
2508 // Prepare and pass only necessary arguments to background process
2509 $attachments_to_send = array(
2510 'args' => array(
2511 'attachments_to_move' => $item_id,
2512 'item_id_array' => $item_id_array,
2513 ),
2514 );
2515
2516 // Run the batch synchronously (this is already inside cron).
2517 $this->mlsimport_background_process_per_item_cron_function( $attachments_to_send['args'] );
2518
2519 // Unset large arrays/objects after use
2520 unset($attachments_to_move, $attachments_to_send, $mlsrequest, $item_id_array);
2521 gc_collect_cycles();
2522 }
2523
2524 return $found_items;
2525 }
2526
2527
2528
2529
2530
2531
2532 /**
2533 * Daily reconciliation: delete local listings that should no longer exist.
2534 *
2535 * Fetches the full set of ListingKeys currently in the MLS feed, guards against
2536 * a truncated feed (which would trigger mass deletion), then walks every local
2537 * listing in batches of 1000 and, per the status rules, deletes those that are
2538 * gone from the feed (or present but in a delete-worthy status). Batched with
2539 * explicit GC to keep memory bounded. Optimized, batched, memory logged.
2540 *
2541 * @return void
2542 */
2543 public function mlsimport_saas_start_doing_reconciliation() {
2544 global $mlsimport, $wpdb;
2545
2546
2547 // Pull every ListingKey the MLS currently reports; free the wrapper array.
2548 // Get all MLS keys in memory (we assume this is necessary for lookup)
2549 $mls_data = $this->mlsimport_saas_get_mls_reconciliation_data();
2550 $listingKey_in_MLS = $mls_data['all_data'] ?? [];
2551
2552 unset($mls_data);
2553 gc_collect_cycles();
2554
2555 // Empty feed -> nothing to reconcile; never delete on an empty response.
2556 if (empty($listingKey_in_MLS)) {
2557 return;
2558 }
2559
2560 // Sanity guard against a well-formed but truncated feed. Reconciliation
2561 // deletes every local listing missing from this list, so a short feed
2562 // would become a mass deletion. Compare the feed size against the local
2563 // ListingKey count (same status filter as the delete loop below) and bail
2564 // if the feed is implausibly small. Log both numbers either way so a
2565 // future incident is diagnosable.
2566 $feed_count = count($listingKey_in_MLS);
2567 $local_count = (int) $wpdb->get_var(
2568 $wpdb->prepare(
2569 "SELECT COUNT(*)
2570 FROM {$wpdb->posts} p
2571 INNER JOIN {$wpdb->postmeta} pm
2572 ON p.ID = pm.post_id AND pm.meta_key = %s
2573 WHERE p.post_status NOT IN ('draft', 'trash')",
2574 'ListingKey'
2575 )
2576 );
2577
2578 error_log(sprintf('MLSimport reconciliation: feed=%d local=%d', $feed_count, $local_count));
2579
2580 if (!mlsimport_reconciliation_feed_is_plausible($feed_count, $local_count)) {
2581 error_log(sprintf(
2582 'MLSimport reconciliation ABORTED: feed of %d is below %d%% of %d local listings; no deletions performed.',
2583 $feed_count,
2584 (int) round(MLSIMPORT_RECONCILIATION_MIN_FEED_FRACTION * 100),
2585 $local_count
2586 ));
2587 return;
2588 }
2589
2590 // Flip so isset($listingKey_in_MLS[$key]) is an O(1) membership test.
2591 // Flip for fast lookup
2592 $listingKey_in_MLS = array_flip($listingKey_in_MLS);
2593
2594 // Batch fetch local listings
2595 $batch = 1000;
2596 $offset = 0;
2597 $to_delete = 0;
2598 $counter = 0;
2599
2600 // One-query preload of status/protect meta for every Import Task, keyed by id.
2601 $mlsimport_preload_all_mls_item_status_meta = $this->mlsimport_preload_all_mls_item_status_meta();
2602 //print_r($mlsimport_preload_all_mls_item_status_meta);
2603
2604 // Page through all local listings 1000 at a time.
2605 do {
2606 $local = $wpdb->get_results(
2607 $wpdb->prepare(
2608 "SELECT
2609 p.ID,
2610 listingkey_meta.meta_value AS listingkey,
2611 inserted_meta.meta_value AS mlsimport_item_inserted
2612 FROM {$wpdb->posts} p
2613 INNER JOIN {$wpdb->postmeta} listingkey_meta
2614 ON p.ID = listingkey_meta.post_id
2615 AND listingkey_meta.meta_key = %s
2616 LEFT JOIN {$wpdb->postmeta} inserted_meta
2617 ON p.ID = inserted_meta.post_id
2618 AND inserted_meta.meta_key = %s
2619 WHERE p.post_status NOT IN ('draft', 'trash')
2620 LIMIT %d OFFSET %d",
2621 'ListingKey',
2622 'MLSimport_item_inserted',
2623 $batch,
2624 $offset
2625 ),
2626 ARRAY_A
2627 );
2628
2629 $count = count($local);
2630
2631
2632 // Decide keep-vs-delete for each local listing in this page.
2633 foreach ($local as $item) {
2634 $listingkey = $item['listingkey']; // not 'meta_value' anymore
2635 $property_id = $item['ID'];
2636 $mlsimportItemId = $item['mlsimport_item_inserted'];
2637 ++$counter;
2638 // IN MLS
2639 if (isset($listingKey_in_MLS[$listingkey])) {
2640
2641 if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) {
2642 $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null;
2643 $mlsimport_item_standardstatusprotect = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusprotect'] ?? null;
2644 } else {
2645 $mlsimport_item_standardstatus = null;
2646 $mlsimport_item_standardstatusprotect = null;
2647 }
2648
2649 // Still in the feed: delete only if its status rules say so.
2650 $keep_when_in_mls = $mlsimport->admin->theme_importer->check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect);
2651 if (!$keep_when_in_mls) {
2652 ++$to_delete;
2653 $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey);
2654 }
2655 } else {
2656 // NOT IN MLS
2657
2658 if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) {
2659 $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null;
2660 $mlsimport_item_standardstatusprotect = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusprotect'] ?? null;
2661 } else {
2662 $mlsimport_item_standardstatus = null;
2663 $mlsimport_item_standardstatusprotect = null;
2664 }
2665 // Gone from the feed: delete unless a protected status keeps it.
2666 $keep = $mlsimport->admin->theme_importer->check_if_delete_when_status($property_id, $mlsimport_item_standardstatus, null, $mlsimport_item_standardstatusprotect);
2667 if (!$keep) {
2668 ++$to_delete;
2669 $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey);
2670 }
2671 }
2672
2673 // Memory housekeeping
2674 unset($listingkey, $property_id, $mlsimportItemId, $mlsImportItemStatus, $mlsimport_item_standardstatusprotect, $keep_when_in_mls, $keep);
2675 if ($counter % 250 == 0) {
2676 gc_collect_cycles();
2677 }
2678 }
2679
2680 unset($local);
2681 gc_collect_cycles();
2682
2683 // Advance; loop until a short page signals the last batch.
2684 $offset += $batch;
2685 } while ($count === $batch);
2686
2687
2688 print esc_html(' to delete:' . $to_delete);
2689
2690 // Final cleanup
2691 unset($listingKey_in_MLS);
2692 gc_collect_cycles();
2693
2694
2695 return;
2696 }
2697
2698
2699
2700 /**
2701 * Preload all status meta for ALL mlsimport_item posts in ONE QUERY.
2702 * Returns: [mlsimport_item_id => ['mlsimport_item_standardstatus' => ..., 'mlsimport_item_standardstatusprotect' => ...], ...]
2703 *
2704 * Avoids per-listing get_post_meta() calls during reconciliation.
2705 *
2706 * @return array<int,array<string,mixed>>
2707 */
2708 function mlsimport_preload_all_mls_item_status_meta() {
2709 global $wpdb;
2710
2711 // Fetch the status + protect meta rows for every Import Task in one pass.
2712 $sql = "
2713 SELECT p.ID as post_id, pm.meta_key, pm.meta_value
2714 FROM {$wpdb->posts} p
2715 LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
2716 WHERE p.post_type = 'mlsimport_item'
2717 AND pm.meta_key IN ('mlsimport_item_standardstatus', 'mlsimport_item_standardstatusprotect')
2718 ";
2719
2720 $rows = $wpdb->get_results($sql);
2721
2722 // Index by post id; unserialize each stored value.
2723 $meta = [];
2724 foreach ($rows as $row) {
2725 if (!isset($meta[$row->post_id])) {
2726 $meta[$row->post_id] = [
2727 'mlsimport_item_standardstatus' => null,
2728 'mlsimport_item_standardstatusprotect' => null
2729 ];
2730 }
2731 $meta[$row->post_id][$row->meta_key] = maybe_unserialize($row->meta_value);
2732 }
2733 return $meta;
2734 }
2735
2736
2737
2738 /**
2739 * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API.
2740 *
2741 * @return array The API response, expected to carry an 'all_data' key.
2742 */
2743 public function mlsimport_saas_get_mls_reconciliation_data() {
2744
2745 // GET /reconciliation with no arguments.
2746 $arguments = array();
2747 $answer = $this->theme_importer->globalApiRequestCurlSaas( 'reconciliation', $arguments, 'GET' );
2748 return $answer;
2749 }
2750
2751 /**
2752 * Return all published posts' values for a given meta key, with their post ids.
2753 *
2754 * @param string $key Meta key to fetch.
2755 * @return array Rows of {meta_value, ID}.
2756 */
2757 public function mlsimport_saas_get_all_meta_values($key) {
2758 global $wpdb;
2759 $result = $wpdb->get_results(
2760 $wpdb->prepare(
2761 "
2762 SELECT pm.meta_value, p.ID
2763 FROM {$wpdb->postmeta} pm
2764 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
2765 WHERE pm.meta_key = %s
2766 AND p.post_status = 'publish'
2767 ",
2768 $key
2769 ),
2770 ARRAY_A // Lighter than OBJECT, unless you need objects
2771 );
2772 return $result;
2773 }
2774
2775
2776
2777 /**
2778 * Run a single listings request for an Import Task and return the API result.
2779 *
2780 * Builds the RESO query arguments, rejects invalid combinations (Rapattoni
2781 * requiring a property type; over-long argument strings), POSTs to the SaaS
2782 * 'listings' endpoint, normalizes a non-array failure into a success=false
2783 * array, records feed-count telemetry, and returns the response array.
2784 *
2785 * @param int $item_id Import Task post id.
2786 * @param string $last_date Modification-time watermark (optional).
2787 * @param string $skip Pagination offset (optional).
2788 * @param string $top Page size (optional).
2789 * @param bool $is_hourly_sync Whether this call is from the hourly cron.
2790 * @return array The (normalized) API response.
2791 */
2792 public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
2793 // Resolve the configured MLS id (drives provider-specific validation).
2794 $options = get_option( $this->plugin_name . '_admin_options' );
2795 $mls_id = '';
2796 if ( isset( $options['mlsimport_mls_name'] ) ) {
2797 $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
2798 }
2799
2800 // Build the full RESO query argument set from the task's meta.
2801 $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top, $is_hourly_sync );
2802
2803
2804 // Rapattoni (5000-5999) requires a property_type; bail with a clear
2805 // message when it is unset/empty (checks the scalar and first element).
2806 if (
2807 $mls_id > 5000 && $mls_id < 6000 &&
2808 ( ! isset( $arguments['property_type'] ) or
2809 ( isset( $arguments['property_type'] ) && '' === $arguments['property_type'] ) or
2810 ( isset( $arguments['property_type'][0] ) && '' === $arguments['property_type'][0] )
2811 )
2812 ) {
2813 return array(
2814 'success' => false,
2815 'type' => 'rapattoni',
2816 'message' => esc_html__( 'This MLS requires to have one item selected from "Property Action Category" dropdown', 'mlsimport' ),
2817 );
2818 }
2819
2820 // Guard against an over-long query string (too many parameters selected).
2821 $potential_leght = strlen( wp_json_encode( $arguments ) );
2822 if ( $potential_leght > 1750 ) {
2823 return array(
2824 'success' => false,
2825 'potential_leght' => $potential_leght,
2826 'message' => esc_html__( 'You have too many parameters selected. Split the import beween multiple MLS Import Tasks: For ex : Import per County instead of selecting 10 cities or import listing between certain price range.', 'mlsimport' ),
2827 );
2828 }
2829
2830 //print_r($arguments);
2831 //print '----------------------------'.PHP_EOL;
2832 // POST the query to the SaaS 'listings' endpoint.
2833 $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
2834
2835 // globalApiRequestCurlSaas() returns a plain string on failure (token
2836 // validation, network/WP error, JSON decode). Callers expect an array,
2837 // so normalize the failure into the success=>false shape they handle.
2838 if ( ! is_array( $answer ) ) {
2839 $answer = array(
2840 'success' => false,
2841 'message' => is_string( $answer ) ? $answer : esc_html__( 'The request to the MLS could not be completed.', 'mlsimport' ),
2842 );
2843 }
2844
2845 // Echo the computed argument length back on the response for diagnostics.
2846 $answer['potential_leght'] = $potential_leght;
2847
2848 // Record the pre-filter MLS feed count for telemetry. Every import path
2849 // — manual, hourly cron, and onboarding — routes through this method, so
2850 // recording here is the single rule that keeps the metric complete.
2851 if ( isset( $answer['results'] ) ) {
2852 mlsimport_telemetry_set( 'last_feed_found', (int) $answer['results'] );
2853 }
2854
2855 return ( $answer );
2856 }
2857
2858
2859
2860
2861
2862
2863 /**
2864 * Assemble the RESO listings query arguments from an Import Task's meta.
2865 *
2866 * Reads the task's saved filters (price, city/county, area, subdivision,
2867 * postal code, status, property (sub)type, internet-display flags, agent /
2868 * office keys and their exclusions, custom parameters) and maps them to the
2869 * SaaS API parameter names, applying provider-specific quirks (Edmonton has
2870 * no status; Rapattoni collapses property_type; Realtor.ca / PropTx need a
2871 * specific modification-time format).
2872 *
2873 * @param int $item_id Import Task post id.
2874 * @param string $last_date Modification-time watermark (optional).
2875 * @param string $skip Pagination offset (optional).
2876 * @param string $top Page size (optional).
2877 * @param bool $is_hourly_sync Whether this call is from the hourly cron.
2878 * @return array|string The argument array, or '' when core options are missing.
2879 */
2880 public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
2881
2882 // MLS id is mandatory.
2883 $options = get_option( $this->plugin_name . '_admin_options' );
2884 if ( isset( $options['mlsimport_mls_name'] ) ) {
2885 $mls_id = intval( $options['mlsimport_mls_name'] );
2886 } else {
2887 return '';
2888 }
2889
2890 // Theme id is mandatory (selects the server-side field schema).
2891 if ( isset( $options['mlsimport_theme_used'] ) ) {
2892 $theme_id = intval( $options['mlsimport_theme_used'] );
2893 } else {
2894 return '';
2895 }
2896
2897 // Base parameters every request carries.
2898 $values = array();
2899 $values['mls_id'] = $mls_id;
2900 $values['theme_id'] = $theme_id;
2901 // Flag hourly-sync calls so the backend can treat them differently.
2902 if ( $is_hourly_sync ) {
2903 $values['hourly_sync'] = 1;
2904 }
2905
2906 // Pagination (only when a page size was supplied).
2907 if ( '' !== $top ) {
2908 $values['top'] = $top;
2909 $values['skip'] = intval( $skip );
2910 }
2911
2912 // // add price
2913 // Price range (only when both bounds are set).
2914 $mlsimport_item_min_price = get_post_meta( $item_id, 'mlsimport_item_min_price', true );
2915 $mlsimport_item_max_price = get_post_meta( $item_id, 'mlsimport_item_max_price', true );
2916 if ( '' !== $mlsimport_item_min_price && '' !== $mlsimport_item_max_price ) {
2917 $values['list_price_min'] = floatval( $mlsimport_item_min_price );
2918 $values['list_price_max'] = floatval( $mlsimport_item_max_price );
2919 }
2920
2921 // add city
2922 $values = $this->mls_import_return_multiple_param_value( 'city', $item_id, 'city', $values );
2923
2924 // add county
2925 $values = $this->mls_import_return_multiple_param_value( 'countyorparish', $item_id, 'county_or_parish', $values );
2926
2927 // add MLSAreaMajor
2928 $values = $this->mls_import_saas_add_to_parms_input( 'MLSAreaMajor', $item_id, 'mls_area_major', $values );
2929
2930 // add SubdivisionName
2931 $values = $this->mls_import_saas_add_to_parms_input( 'SubdivisionName', $item_id, 'subdivision_name', $values );
2932
2933 // add postal code
2934 $values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values );
2935
2936 // add status
2937
2938 // Edmonton (111) has no StandardStatus field, so skip it there.
2939 if ( 111 !== $mls_id ) { // edmonton check
2940 $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values );
2941 }
2942
2943 // add property_subtype
2944 $values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values );
2945
2946 // add property_type
2947 $values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values );
2948
2949 // Rapattoni exception: property_type must be a single, space-stripped,
2950 // single-element array rather than the multi-select list.
2951 // rapattoni exception
2952 if ( $mls_id > 5000 &&
2953 ( isset($values['property_type']) && $values['property_type'] !='' ) ) {
2954
2955 $values = $this->mls_import_saas_add_to_parms_input( 'PropertyType', $item_id, 'property_type', $values );
2956 $temp = $values['property_type'];
2957 $temp = str_replace( ' ', '', $temp );
2958 $values['property_type'] = array();
2959 $values['property_type'][] = $temp;
2960 }
2961
2962 // add internet_entirelisting_displayyn
2963 $values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values );
2964
2965 // add internet_address_displayyn
2966 $values = $this->mls_import_saas_add_to_parms_input( 'InternetAddressDisplayYN', $item_id, 'internet_address_displayyn', $values );
2967
2968 // add ListAgentKey
2969 $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentKey', $item_id, 'list_agentkey', $values );
2970 // add ListAgentKey
2971 $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentMlsId', $item_id, 'list_agentmlsid', $values );
2972 // add BuyerAgentMlsId
2973 $values = $this->mls_import_saas_add_to_parms_input( 'BuyerAgentMlsId', $item_id, 'buyer_agentmlsid', $values );
2974 // add ListOfficeKey
2975 $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeKey', $item_id, 'list_officekey', $values );
2976 // add ListOfficeMlsId
2977 $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeMlsId', $item_id, 'list_officemlsid', $values );
2978
2979 // add ListingId
2980 $values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values );
2981
2982 //add Exclude_ListOfficeKey
2983 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values );
2984 // add Exclude_ListOfficeMlsId
2985 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values );
2986
2987
2988
2989 //add Exclude_ListAgentKey
2990 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentKey', $item_id, 'exclude_list_agentkey', $values );
2991 // add Exclude_ListAgentMlsId
2992 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentMlsId', $item_id, 'exclude_list_agentmlsid', $values );
2993 // add CustomParameters
2994 $values = $this->mls_import_saas_add_to_parms_input( 'CustomParameters', $item_id, 'custom_parameters', $values );
2995
2996
2997 // Realtor.ca (7000-7999) expects an ISO UTC timestamp with seconds/Z.
2998 // if we have realtorca
2999 if ($mls_id >= 7000 && $mls_id < 8000 && $last_date!=='') {
3000 $dateTime_realtorca = new DateTime($last_date, new DateTimeZone('UTC'));
3001 // Format with seconds and UTC timezone marker
3002 $last_date = $dateTime_realtorca->format('Y-m-d\TH:i:s.000\Z');
3003 }
3004
3005 // PropTx / AMPRE requires a full OData DateTimeOffset literal for the ModificationTimestamp filter.
3006 if ( mlsimport_is_proptx_provider( $mls_id ) && $last_date !== '' ) {
3007 $last_date = mlsimport_format_odata_modification_time( $last_date );
3008 }
3009
3010 // Attach the (possibly reformatted) modification-time watermark.
3011 if ( '' !== $last_date ) {
3012 $values['modification_time'] = $last_date;
3013 }
3014
3015 return( $values );
3016 }
3017
3018
3019
3020 /**
3021 * Copy a single scalar Import Task meta value into the arguments array.
3022 *
3023 * Reads mlsimport_item_<key> and, when non-empty, stores it under $new_name.
3024 *
3025 * @param string $key Field key (used to build the meta key).
3026 * @param int $post_id Import Task post id.
3027 * @param string $new_name API parameter name to store under.
3028 * @param array $all_values Accumulating arguments array.
3029 * @return array The updated arguments array.
3030 */
3031 public function mls_import_saas_add_to_parms_input( $key, $post_id, $new_name, $all_values ) {
3032 // Read the scalar meta value and add it only when set.
3033 $name = strtolower( 'mlsimport_item_' . $key );
3034 $value = get_post_meta( $post_id, $name, true );
3035 if ( '' !== $value ) {
3036 $all_values[ $new_name ] = $value;
3037 }
3038
3039 return $all_values;
3040 }
3041
3042
3043 /**
3044 * Copy a multi-value (list) Import Task meta value into the arguments array.
3045 *
3046 * Reads the list value plus its "_check" (select-all) flag; for city/county
3047 * it also merges any comma-separated "extra" free-text values. The value is
3048 * added only when select-all is off and it is non-empty — except 'status',
3049 * which is always written.
3050 *
3051 * @param string $key Field key (used to build the meta keys).
3052 * @param int $post_id Import Task post id.
3053 * @param string $new_name API parameter name to store under.
3054 * @param array $all_values Accumulating arguments array.
3055 * @return array The updated arguments array.
3056 */
3057 public function mls_import_return_multiple_param_value( $key, $post_id, $new_name, $all_values ) {
3058 // The selected list value and its companion select-all flag.
3059 $name_check = strtolower( 'mlsimport_item_' . $key . '_check' );
3060 $name = strtolower( 'mlsimport_item_' . $key );
3061
3062 $value = get_post_meta( $post_id, $name, true );
3063
3064 // add extra county - should be moved into function if pass tests
3065 if ( 'countyorparish' === $key ) {
3066 $extracounty_values = get_post_meta( $post_id, 'mlsimport_item_extracounty', true );
3067
3068 if ( '' !== $extracounty_values ) {
3069 $extracounty_array = explode( ',', $extracounty_values );
3070
3071 if ( ! is_array( $value ) ) {
3072 if ( '' === $value ) {
3073 $value = array();
3074 } else {
3075 $value = array( $value );
3076 }
3077 }
3078
3079 foreach ( $extracounty_array as $extra ) {
3080 $value[] = $extra;
3081 }
3082 }
3083 }
3084
3085 // add extra city - should be moved into function if pass tests
3086 if ( 'city' === $key ) {
3087 $extracity_values = get_post_meta( $post_id, 'mlsimport_item_extracity', true );
3088 if ( '' !== $extracity_values ) {
3089 $extracity_array = explode( ',', $extracity_values );
3090
3091 if ( ! is_array( $value ) ) {
3092 if ('' === $value ) {
3093 $value = array();
3094 } else {
3095 $value = array( $value );
3096 }
3097 }
3098
3099 foreach ( $extracity_array as $extra ) {
3100 $value[] = $extra;
3101 }
3102 }
3103 }
3104
3105 // Only include the list when "select all" is off and there is a value.
3106 $value_check = get_post_meta( $post_id, $name_check, true );
3107
3108 if ( 0 === intval($value_check) && '' !== $value ) {
3109 $all_values[ $new_name ] = $value;
3110 }
3111
3112 // status exception: always send status, regardless of the check flag.
3113 if ( 'status' === $new_name ) {
3114 $all_values[ $new_name ] = $value;
3115 }
3116
3117 return $all_values;
3118 }
3119
3120
3121
3122 /**
3123 * Build the Import Task field definition list (labels, types, enum values).
3124 *
3125 * Reads the saved MLS enums option, extracts the available City / County /
3126 * status / property (sub)type value lists, and returns the ordered field
3127 * definition array the metabox renders from. Falls back StandardStatus to
3128 * MlsStatus when the MLS has no StandardStatus enum. Emits a warning when no
3129 * metadata has been fetched yet.
3130 *
3131 * @return array Field key => definition (label, description, type, multiple, values).
3132 */
3133 public function mlsimport_saas_return_mls_fields() {
3134
3135 // Saved MLS enum metadata (JSON); empty until fields have been fetched.
3136 $mlsimport_mls_metadata_mls_enums = get_option( 'mlsimport_mls_metadata_mls_enums', '' );
3137
3138 // Warn the user when no metadata is available yet.
3139 if ( '' === $mlsimport_mls_metadata_mls_enums ) {
3140 ?>
3141 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
3142 <?php
3143 }
3144
3145 // Decode and reach into the enum container.
3146 $metadata_api_call_full = json_decode( $mlsimport_mls_metadata_mls_enums, true );
3147
3148 if ( isset( $metadata_api_call_full['global_array'] ) ) {
3149 $metadata_api_call = $metadata_api_call_full['global_array'];
3150 }
3151
3152 // Extract each enum list as a flat array of option keys (empty if absent).
3153 $city_array = array();
3154 if ( isset( $metadata_api_call['PropertyEnums']['City'] ) && is_array( $metadata_api_call['PropertyEnums']['City'] ) ) {
3155 $city_array = array_keys( $metadata_api_call['PropertyEnums']['City'] );
3156 }
3157
3158 $county_array = array();
3159 if ( isset( $metadata_api_call['PropertyEnums']['CountyOrParish'] ) && is_array( $metadata_api_call['PropertyEnums']['CountyOrParish'] ) ) {
3160 $county_array = array_keys( $metadata_api_call['PropertyEnums']['CountyOrParish'] );
3161 }
3162
3163 $mlsstatus_array = array();
3164 if ( isset( $metadata_api_call['PropertyEnums']['MlsStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['MlsStatus'] ) ) {
3165 $mlsstatus_array = array_keys( $metadata_api_call['PropertyEnums']['MlsStatus'] );
3166 }
3167
3168 $propertysubtype_array = array();
3169 if ( isset( $metadata_api_call['PropertyEnums']['PropertySubType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertySubType'] ) ) {
3170 $propertysubtype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertySubType'] );
3171 }
3172
3173 $propertytype_array = array();
3174 if ( isset( $metadata_api_call['PropertyEnums']['PropertyType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertyType'] ) ) {
3175 $propertytype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertyType'] );
3176 }
3177
3178
3179 $standardstatus_array = array();
3180 if ( isset( $metadata_api_call['PropertyEnums']['StandardStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['StandardStatus'] ) ) {
3181 $standardstatus_array = array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
3182 }
3183
3184 // if we do not have standart status
3185 // Fall back to MlsStatus values when the MLS exposes no StandardStatus.
3186 if ( empty( $standardstatus_array ) ) {
3187 $standardstatus_array = $mlsstatus_array;
3188 }
3189
3190
3191
3192
3193 // Free-text "extra" inputs render empty; they hold comma-separated values.
3194 $extracounty_values = '';
3195 $extracity_values = '';
3196
3197 // Ordered field definitions consumed by the Import Task metabox renderer.
3198 $field_import = array(
3199 'City' => array(
3200 'label' => esc_html__( 'Select cities', 'mlsimport' ),
3201 'description' => esc_html__( 'Select the cities from where we will import data.', 'mlsimport' ),
3202 'type' => 'select',
3203 'multiple' => 'yes',
3204 'values' => $city_array,
3205 ),
3206
3207 'extraCity' => array(
3208 'label' => esc_html__( 'Add extra Cities', 'mlsimport' ),
3209 'description' => esc_html__( 'Add extra cities, separated by comma. They need to be written exactly like they are stored in MLS (for example all caps)', 'mlsimport' ),
3210 'type' => 'input',
3211 'multiple' => 'no',
3212 'values' => $extracity_values,
3213 ),
3214
3215 'CountyOrParish' => array(
3216 'label' => esc_html__( 'Select Counties', 'mlsimport' ),
3217 'description' => esc_html__( 'Select the counties from where we will import data.', 'mlsimport' ),
3218 'type' => 'select',
3219 'multiple' => 'yes',
3220 'values' => $county_array,
3221 'show_extra_field' => true,
3222 ),
3223
3224 'extraCounty' => array(
3225 'label' => esc_html__( 'Add extra Counties', 'mlsimport' ),
3226 'description' => esc_html__( 'Add extra counties, separated by comma. They need to be written exactly like they are stored in MLS (for example all caps)', 'mlsimport' ),
3227 'type' => 'input',
3228 'multiple' => 'no',
3229 'values' => $extracounty_values,
3230 ),
3231
3232 'MLSAreaMajor' => array(
3233 'label' => esc_html__( 'MLS Area Major', 'mlsimport' ),
3234 'description' => esc_html__( 'Filter listings by MLSAreaMajor.', 'mlsimport' ),
3235 'type' => 'input',
3236 'multiple' => 'no',
3237 ),
3238
3239 'SubdivisionName' => array(
3240 'label' => esc_html__( 'Subdivision Name', 'mlsimport' ),
3241 'description' => esc_html__( 'Filter listings by SubDivisionName.', 'mlsimport' ),
3242 'type' => 'input',
3243 'multiple' => 'no',
3244 ),
3245
3246 'PostalCode' => array(
3247 'label' => esc_html__( 'Select Postal Code', 'mlsimport' ),
3248 'description' => esc_html__( 'Enter one or more postal codes to import listings from, separated by commas (e.g. 12345, 23456).', 'mlsimport' ),
3249 'type' => 'input',
3250 'multiple' => 'no',
3251 ),
3252
3253 'PropertySubType' => array(
3254 'label' => esc_html__( 'Select Property Category', 'mlsimport' ),
3255 'description' => esc_html__( 'Property Category', 'mlsimport' ),
3256 'type' => 'select',
3257 'multiple' => 'yes',
3258 'values' => $propertysubtype_array,
3259 ),
3260 'PropertyType' => array(
3261 'label' => esc_html__( 'Select Property Action Category', 'mlsimport' ),
3262 'description' => esc_html__( 'Property Action Category', 'mlsimport' ),
3263 'type' => 'select',
3264 'multiple' => 'yes',
3265 'values' => $propertytype_array,
3266 ),
3267 'StandardStatus' => array(
3268 'label' => esc_html__( 'Select Status', 'mlsimport' ),
3269 'description' => __( 'The list is auto-populated with MLS available statuses. To select multiple statuses, use Ctrl (Windows) or Command (Mac).', 'mlsimport' ),
3270 'type' => 'select',
3271 'multiple' => 'yes',
3272 'values' => $standardstatus_array,
3273 ),
3274 'StandardStatusProtect' => array(
3275 'label' => esc_html__( 'Protected Statuses', 'mlsimport' ),
3276 'description' => __( 'Properties with these statuses will NEVER be deleted from your website during reconciliation, even if they are no longer found in the MLS. Use this to protect Closed, Expired, or other non-active listings from automatic deletion.', 'mlsimport' ),
3277 'type' => 'select',
3278 'multiple' => 'yes',
3279 'values' => $standardstatus_array,
3280 ),
3281
3282 'InternetEntireListingDisplayYN' => array(
3283 'label' => esc_html__( 'Internet Entire Listing Display ', 'mlsimport'),
3284 'description' => esc_html__( 'A yes/no field that states the seller has allowed the listing to be displayed on Internet sites.', 'mlsimport' ),
3285 'type' => 'select',
3286 'multiple' => 'no',
3287 'values' => array(
3288 'yes',
3289 'no',
3290 ),
3291 ),
3292 'InternetAddressDisplayYN' => array(
3293 'label' => esc_html__( 'Internet Address display', 'mlsimport' ),
3294 'description' => esc_html__( 'A yes/no field that states the seller has allowed the listing address to be displayed on Internet sites.', 'mlsimport' ),
3295 'type' => 'select',
3296 'multiple' => 'no',
3297 'values' => array(
3298 'yes',
3299 'no',
3300 ),
3301 ),
3302 'ListAgentKey' => array(
3303 'label' => esc_html__( 'ListAgentKey', 'mlsimport' ),
3304 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
3305 'type' => 'input',
3306 'multiple' => 'no',
3307 ),
3308 'ListAgentMlsId' => array(
3309 'label' => esc_html__( 'ListAgentMlsId', 'mlsimport' ),
3310 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
3311 'type' => 'input',
3312 'multiple' => 'no',
3313 ),
3314 'BuyerAgentMlsId' => array(
3315 'label' => esc_html__( 'BuyerAgentMlsId', 'mlsimport' ),
3316 'description' => esc_html__( 'Import listings from a specific Buyer Agent (contact your MLS for this information)', 'mlsimport' ),
3317 'type' => 'input',
3318 'multiple' => 'no',
3319 ),
3320 'ListOfficeKey' => array(
3321 'label' => esc_html__( 'ListOfficeKey', 'mlsimport' ),
3322 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport'),
3323 'type' => 'input',
3324 'multiple' => 'no',
3325 ),
3326 'ListOfficeMlsId' => array(
3327 'label' => esc_html__( 'ListOfficeMlsId', 'mlsimport' ),
3328 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport' ),
3329 'type' => 'input',
3330 'multiple' => 'no',
3331 ),
3332 'ListingId' => array(
3333 'label' => esc_html__( 'ListingId', 'mlsimport' ),
3334 'description' => esc_html__( 'Import One Property Only via parameter ListingID. If this does not work for you, please contact us to check if the field exists in your MLS.', 'mlsimport'),
3335 'type' => 'input',
3336 'multiple' => 'no',
3337 ),
3338 'Exclude_ListOfficeMlsId' => array(
3339 'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ),
3340 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ),
3341 'type' => 'input',
3342 'multiple' => 'no',
3343 ),
3344 'Exclude_ListOfficeKey' => array(
3345 'label' => esc_html__( 'Exclude listings with ListOfficeKey', 'mlsimport' ),
3346 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeKey', 'mlsimport'),
3347 'type' => 'input',
3348 'multiple' => 'no',
3349 ),
3350
3351
3352 'Exclude_ListAgentMlsId' => array(
3353 'label' => esc_html__( 'Exclude listings with ListAgentMlsId', 'mlsimport' ),
3354 'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentMlsId.', 'mlsimport' ),
3355 'type' => 'input',
3356 'multiple' => 'no',
3357 ),
3358 'Exclude_ListAgentKey' => array(
3359 'label' => esc_html__( 'Exclude listings with ListAgentKey ', 'mlsimport' ),
3360 'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentKey ', 'mlsimport'),
3361 'type' => 'input',
3362 'multiple' => 'no',
3363 ),
3364 'CustomParameters' => array(
3365 'label' => esc_html__( 'Custom parameters', 'mlsimport' ),
3366 'description' => esc_html__( 'Add raw query fragment parameters (for example: $filter=WaterfrontYN eq true). They will be forwarded to the RESO API request.', 'mlsimport' ),
3367 'type' => 'input',
3368 'multiple' => 'no',
3369 ),
3370
3371
3372 );
3373 return $field_import;
3374 }
3375
3376
3377
3378
3379
3380
3381
3382 /**
3383 * AJAX: kick off a manual import for one Import Task.
3384 *
3385 * Resets the force-stop flag, builds the paginated batch of request-argument
3386 * sets, stores them (and zeroed progress meta), marks the task 'started', and
3387 * enqueues the Action Scheduler background job that does the actual import.
3388 * Returns any build error immediately, otherwise {success:true}.
3389 *
3390 * @return void Emits JSON.
3391 */
3392 public function mlsimport_move_files_per_item() {
3393 // CSRF.
3394 check_ajax_referer( 'mlsimport_item_actions', 'security' );
3395 // Read + default the task id and count inputs.
3396 $post_id = 0;
3397 $how_many = 0;
3398 $max_number = 0;
3399 if(isset( $_POST['post_id'] )){
3400 $post_id = intval( $_POST['post_id'] );
3401 }
3402 if(isset( $_POST['how_many'] )){
3403 $how_many = intval( $_POST['how_many'] );
3404 }
3405 if(isset( $_POST['post_number'] )){
3406 $max_number = intval( $_POST['post_number'] );
3407 }
3408
3409 // Admin boundary: the target must be an Import Task the user can edit.
3410 if ( 'mlsimport_item' !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
3411 wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 );
3412 }
3413
3414 // Whether the request comes from the onboarding wizard.
3415 $is_onboard=intval($_POST['is_onboard']);
3416
3417 // Clear any prior stop flag so this run proceeds (autoload off).
3418 update_option( 'mlsimport_force_stop_' . $post_id, 'no', false );
3419
3420 $item_id_array = array(
3421 'item_id' => $post_id,
3422 'how_many' => $how_many,
3423 'max_number' => $max_number,
3424 'batch_counter' => 1,
3425 );
3426
3427
3428 // Clear any stale batch, then build the fresh batch of requests.
3429 update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, '' );
3430
3431 $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array );
3432
3433 // If an error was returned from the API, sanitize and send it back to the client and stop further processing.
3434 if ( isset( $attachments_to_move['success'] ) && false === $attachments_to_move['success'] ) {
3435 if ( isset( $attachments_to_move['message'] ) ) {
3436 $attachments_to_move['message'] = wp_strip_all_tags( $attachments_to_move['message'] );
3437 }
3438 wp_send_json( $attachments_to_move );
3439 wp_die();
3440 }
3441
3442 // Persist the batch for the background worker to consume.
3443 update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, $attachments_to_move );
3444
3445 // net stat data
3446 // Reset progress counters shown in the UI.
3447 update_post_meta( $post_id, 'mlsimport_progress_properties', 0 );
3448 update_post_meta( $post_id, 'mlsimport_progress_batches', 0 );
3449 update_post_meta( $post_id, 'mlsimport_progress_memory', 0 );
3450
3451
3452
3453 $attachments_to_send = array(
3454 'args' => array(
3455 'attachments_to_move' => $post_id,
3456 'item_id_array' => $item_id_array,
3457 'is_onboard' =>$is_onboard,
3458 ),
3459 );
3460
3461 mlsimport_saas_single_write_import_custom_logs( 'Preparing the import. Please hold on.' . PHP_EOL );
3462 mlsimport_debuglogs_per_plugin( 'Preparing the import. Please hold on.' . PHP_EOL );
3463
3464 // Mark the task started, then enqueue the async import job.
3465 update_post_meta( $post_id, 'mlsimport_spawn_status', 'started' );
3466
3467 // old
3468 as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send );
3469
3470 // Remove any pending async jobs for this item and enqueue a unique one
3471 //bad ideea
3472 // as_unschedule_all_actions( 'mlsimport_background_process_per_item', $attachments_to_send );
3473 //as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send, '', true );
3474
3475
3476 // Nudge WP-Cron so the queued action runs promptly.
3477 spawn_cron();
3478
3479 unset( $attachments_to_send );
3480
3481 // Return success response to the AJAX caller.
3482 wp_send_json( array( 'success' => true ) );
3483 }
3484
3485 /**
3486 * Process MLS Import attachments via background cron.
3487 * Memory-optimized with detailed memory usage logging.
3488 *
3489 * @param array $input_arg
3490 * @return void
3491 */
3492 public function mlsimport_background_process_per_item_cron_function( $input_arg ) {
3493 global $mlsimport;
3494
3495 $log = 'In cron processing function ->' . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL;
3496 mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3497 mlsimport_saas_single_write_import_custom_logs( '[Memory] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' );
3498
3499 // Load attachments to move from post meta
3500 $attachments_to_move = get_post_meta(
3501 $input_arg['item_id_array']['item_id'],
3502 'mlsimport_cron_attach_to_move_' . $input_arg['item_id_array']['item_id'],
3503 true
3504 );
3505 $log = '[Memory] After loading attachments: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3506 mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3507
3508 // Run each request batch: call the API and parse the results, freeing
3509 // memory between iterations.
3510 if (!empty($attachments_to_move) && is_array($attachments_to_move)) {
3511 foreach ($attachments_to_move as $key => $import_arguments) {
3512 // Optionally clear any cache for this batch
3513 if ( isset($GLOBALS['wp_object_cache']) ) {
3514 $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
3515 }
3516
3517 $log = '[Memory] Before API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3518 mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3519
3520 // API call
3521 $api_call_array = $this->theme_importer->globalApiRequestCurlSaas('listings', $import_arguments, 'POST');
3522
3523 $log = '[Memory] After API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3524 mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3525
3526 // Parse/process response
3527 $mlsimport->admin->theme_importer->mlsimportSaasCronParseSearchArrayPerItem(
3528 $api_call_array, $input_arg['item_id_array'], $key
3529 );
3530
3531 // Free per-iteration memory
3532 unset($api_call_array, $import_arguments);
3533 gc_collect_cycles();
3534
3535 $log = '[Memory] After cleanup batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3536 mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3537 }
3538 }
3539
3540 mlsimport_saas_single_write_import_custom_logs('[Memory] End: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' );
3541 mlsimport_saas_single_write_import_custom_logs('CRON JOB Import Completed ' . PHP_EOL, 'cron');
3542 mlsimport_debuglogs_per_plugin('CRON JOB Import Completed ' . PHP_EOL);
3543 update_post_meta($input_arg['item_id_array']['item_id'], 'mlsimport_spawn_status', 'completed');
3544
3545 unset($attachments_to_move, $input_arg, $log);
3546 gc_collect_cycles();
3547 }
3548
3549
3550
3551
3552 /**
3553 * Build the paginated list of request-argument sets for an import run.
3554 *
3555 * Resolves how many listings to fetch (0 => all found, capped at max_found
3556 * and a hard 10000 ceiling), then produces one argument set per page of 25.
3557 * Propagates an API error set immediately instead of a batch list.
3558 *
3559 * @param array $item_id_array {item_id, how_many, max_number, batch_counter}.
3560 * @param string $last_date Modification-time watermark (optional).
3561 * @param bool $is_hourly_sync Whether this is an hourly-cron run.
3562 * @return array Array of argument sets, or an API error array.
3563 */
3564 public function mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date = '', $is_hourly_sync = false ) {
3565 // Page size for each batched listings request.
3566 $import_step = 25;
3567
3568 // Resolve the effective count: 0 means "all found".
3569 $prop_id = $item_id_array['item_id'];
3570 $max_found = $item_id_array['max_number'];
3571 $how_many = $item_id_array['how_many'];
3572 if ( 0=== intval($how_many) ) {
3573 $how_many = $max_found;
3574 }
3575 // Never request more than actually exist.
3576 if ( $how_many > $max_found ) {
3577 $how_many = $max_found;
3578 }
3579
3580 $search_url_step = '';
3581 $urls_array = array();
3582
3583 // Hard ceiling of 10000 per task; record the planned total for progress.
3584 $skip = 0;
3585 if ( $how_many > 10000 ) {
3586 $how_many = 10000;
3587 }
3588 update_post_meta($prop_id,'mlsimport_task_to_import', intval($how_many) );
3589
3590 // Shrink the page size if fewer than one page remain.
3591 if ( $how_many < $import_step ) {
3592 $import_step = $how_many;
3593 }
3594
3595 // Emit one argument set per page until the target count is reached.
3596 while ( $skip < $how_many ) {
3597
3598 // Determine how many items to request for this batch.
3599 $batch_step = min( $import_step, $how_many - $skip );
3600
3601 // Build the request arguments using the remaining count.
3602 $search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $batch_step, $is_hourly_sync );
3603
3604 // If the API returned an error, propagate it immediately.
3605 if ( isset( $search_url_step['success'] ) && false === $search_url_step['success'] ) {
3606 return $search_url_step;
3607 }
3608
3609 $skip += $batch_step;
3610 $urls_array[] = $search_url_step;
3611
3612
3613 }
3614 return $urls_array;
3615 }
3616
3617
3618
3619
3620
3621
3622
3623 /**
3624 * Action Scheduler worker: run a manual import's batches for one task.
3625 *
3626 * Loads the pre-built batch and the task's option meta once, then iterates
3627 * each batch — calling the listings API and parsing results into posts —
3628 * while honoring the force-stop flag and flushing memory between batches.
3629 * Marks the task 'completed' and clears the batch meta when finished.
3630 *
3631 * @param array $input_arg {item_id_array:{item_id,...}, ...}.
3632 * @return void
3633 */
3634 public function mlsimport_background_process_per_item_function( $input_arg ) {
3635
3636 // Task id + a log prefix identifying it.
3637 $mlsimportItemId = $input_arg['item_id_array']['item_id'];
3638 $log_prefix = 'In processing function - Item ID: ' . $mlsimportItemId . ' -> ';
3639 mlsimport_saas_single_write_import_custom_logs( $log_prefix . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL );
3640
3641
3642 // Get from MLS Import the big argument array only once
3643 $attachments_to_move = get_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId, true );
3644
3645 // No batch payload: the meta is '' (stale clear, failed start, or already
3646 // deleted by a completed run). count()/foreach on a string throws TypeError
3647 // on PHP 8, so bail out and release the task instead.
3648 if ( ! is_array( $attachments_to_move ) || empty( $attachments_to_move ) ) {
3649 mlsimport_saas_single_write_import_custom_logs( $log_prefix . 'No batch payload found - nothing to import.' . PHP_EOL );
3650 update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
3651 return;
3652 }
3653
3654 // Retrieve all meta data in one go to reduce database queries
3655 $mlsimport_item_option_data = array(
3656 'mlsimport_item_standardstatus' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatus', true ),
3657 'mlsimport_item_standardstatusprotect' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatusprotect', true ),
3658 'mlsimport_item_property_user' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_user', true ),
3659 'mlsimport_item_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_agent', true ),
3660 'mlsimport_item_use_mls_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_use_mls_agent', true ),
3661 'mlsimport_item_property_status' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_status', true ),
3662 );
3663
3664 $total_batches = count( $attachments_to_move );
3665
3666 // removed because $this
3667 global $mlsimport;
3668
3669 $log = 'In processing function $attachments_to_move ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
3670 mlsimport_saas_single_write_import_custom_logs( $log );
3671
3672
3673 // Process each batch unless the user has requested a stop.
3674 foreach ( $attachments_to_move as $key => $import_arguments ) {
3675 // reconsider use
3676 // $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
3677 // Re-read the stop flag each batch so a stop takes effect mid-run.
3678 $status = get_option( 'mlsimport_force_stop_' . $mlsimportItemId );
3679 if ( 'no' === $status ) {
3680 // Clear memory before processing each batch
3681 wp_cache_flush();
3682 gc_collect_cycles();
3683
3684 // wp_cache_flush();
3685 $mem_usage = memory_get_usage( true );
3686 $mem_usage_show = round( $mem_usage / 1048576, 2 );
3687
3688 update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $key + 1 );
3689 update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $mem_usage_show );
3690
3691
3692
3693 mlsimport_saas_single_write_import_custom_logs( $log );
3694 $log = 'Parsing import batch: ' . ( $key + 1 ) . ' of ' . $total_batches . '. Memory used: ' . $mem_usage_show . ' MB.' . PHP_EOL;
3695
3696
3697 // Combine logs and reduce function calls
3698 mlsimport_saas_single_write_import_custom_logs( $log );
3699 mlsimport_debuglogs_per_plugin( $log );
3700 print esc_html($log);
3701
3702 // Fetch this batch of listings and turn them into posts.
3703 $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
3704
3705 $mlsimport->admin->theme_importer->mlsimportSaasParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key, $mlsimport_item_option_data );
3706
3707
3708
3709 // Explicitly unset large variables after each batch
3710 unset($api_call_array);
3711
3712 // Force garbage collection again after processing
3713 wp_cache_flush();
3714 gc_collect_cycles();
3715
3716
3717 // Add a small delay to allow memory to be freed
3718 if (($key + 1) < $total_batches) {
3719 usleep(100000); // 100ms pause between batches
3720 }
3721
3722 } else {
3723
3724 // Force-stop requested: finalize as completed and break out.
3725 $final_mem_usage = memory_get_usage( true );
3726 $final_mem_usage_show = round( $final_mem_usage / 1048576, 2 );
3727
3728
3729 update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
3730 delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
3731
3732 //new stats
3733 update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $total_batches );
3734 update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $final_mem_usage_show );
3735
3736
3737 mlsimport_saas_single_write_import_custom_logs( PHP_EOL . 'Parsing importing link FORCE STOP : ' );
3738 mlsimport_debuglogs_per_plugin( 'Parsing importing link FORCE STOP : ' );
3739 break; // Exit the loop if forced to stop
3740 }
3741 }
3742
3743 // All batches done: mark completed and drop the stored batch payload.
3744 mlsimport_saas_single_write_import_custom_logs( 'Import Completed ' . PHP_EOL );
3745 mlsimport_debuglogs_per_plugin( 'Import Completed ' . PHP_EOL );
3746
3747 update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
3748 delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
3749
3750 // Final cleanup
3751 unset($attachments_to_move);
3752 unset($mlsimport_item_option_data);
3753 unset($input_arg);
3754 unset($log);
3755
3756 // One final garbage collection
3757 wp_cache_flush();
3758 gc_collect_cycles();
3759 }
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769 /**
3770 * AJAX: poll import status/logs for a task (drives the progress UI).
3771 *
3772 * Admin-only; accepts either the import-task or onboarding nonce. Reads the
3773 * status log file plus progress meta and returns a JSON payload flagged
3774 * 'done' (stopped/completed) or 'wip' (in progress).
3775 *
3776 * @return void Emits JSON then dies.
3777 */
3778 public function mlsimport_logger_per_item() {
3779 // Authorization: only administrators may read import logs/status
3780 // (consistent with mlsimport_get_taxonomy_terms()).
3781 if ( ! current_user_can( 'administrator' ) ) {
3782 wp_send_json_error( 'Unauthorized' );
3783 }
3784 // CSRF: accept the nonce from either legitimate caller — the import-task
3785 // screen (mlsimport_item_actions) or the onboarding wizard (mlsimport_onboarding_nonce).
3786 if ( ! check_ajax_referer( 'mlsimport_item_actions', 'security', false )
3787 && ! check_ajax_referer( 'mlsimport_onboarding_nonce', 'security', false ) ) {
3788 wp_send_json_error( array( 'message' => 'invalid nonce' ), 403 );
3789 }
3790 $post_id=0;
3791 if(isset($_POST['post_id'] )){
3792 $post_id = intval( $_POST['post_id'] );
3793 }
3794
3795 // Current spawn status + the shared status log file contents.
3796 $status = get_post_meta( $post_id, 'mlsimport_spawn_status', true );
3797 $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
3798 $logs = file_get_contents( $path );
3799
3800 //get neww status data
3801 // Progress counters written by the background worker.
3802 $current = intval( get_post_meta( $post_id, 'mlsimport_progress_properties', true ) );
3803 $total = intval( get_post_meta( $post_id, 'mlsimport_progress_batches', true ) );
3804 $memory = get_post_meta( $post_id, 'mlsimport_progress_memory', true );
3805 $mlsimport_task_to_import = intval( get_post_meta($post_id, 'mlsimport_task_to_import',true));
3806
3807
3808
3809 // Stop flag: the option is authoritative (second assignment wins).
3810 $force_status = intval( get_post_meta( $post_id, 'mlsimport_force_stop', true ) );
3811 $force_status = get_option( 'mlsimport_force_stop_' . $post_id );
3812
3813 // Stop requested -> report done.
3814 if ( 'no' !== $force_status ) {
3815 echo wp_json_encode(
3816 array(
3817 'is_done' => 'done',
3818 'status' => $status,
3819 'logs' => $logs,
3820 )
3821 );
3822 die();
3823 }
3824
3825 // Empty/completed status -> report done; otherwise report wip.
3826 if ( '' === $status || 'completed' === $status ) {
3827 echo wp_json_encode(
3828 array(
3829 'is_done' => 'done',
3830 'status' => $status,
3831 'logs' => $logs,
3832 'mlsimport_progress_properties' => $current,
3833 'mlsimport_task_to_import' => $total,
3834 )
3835 );
3836 } else {
3837 // return from log
3838 echo wp_json_encode(
3839 array(
3840 'is_done' => 'wip',
3841 'status' => $status,
3842 'logs' => $logs,
3843 'mlsimport_progress_properties' => $current,
3844 'mlsimport_progress_batches' => $total,
3845 'memory' => $memory,
3846 'mlsimport_task_to_import'=>$mlsimport_task_to_import,
3847 'post_id'=>$post_id
3848
3849 )
3850 );
3851 }
3852 die();
3853 }
3854
3855
3856
3857
3858
3859
3860 /**
3861 * AJAX: request a force-stop of a running import for one task.
3862 *
3863 * Sets the per-task force-stop option to 'yes' and clears its object-cache
3864 * entry so the in-flight background loop notices on its next iteration.
3865 *
3866 * @return void Emits JSON success.
3867 */
3868 public function mlsimport_stop_import_per_item() {
3869
3870
3871 // CSRF + read the task id.
3872 check_ajax_referer( 'mlsimport_item_actions', 'security' );
3873 $post_id=0;
3874 if(isset($_POST['post_id'] )){
3875 $post_id = intval( $_POST['post_id'] );
3876 }
3877 // Admin boundary: the target must be an Import Task the user can edit.
3878 if ( 'mlsimport_item' !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
3879 wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 );
3880 }
3881 // Flip the stop flag (autoload off).
3882 update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false );
3883 // ensure caches are cleared so running processes see the update immediately
3884 if ( function_exists( 'wp_cache_delete' ) ) {
3885 wp_cache_delete( 'mlsimport_force_stop_' . $post_id, 'options' );
3886 }
3887 mlsimport_saas_single_write_import_custom_logs( 'Stopped for ' . $post_id . PHP_EOL );
3888 mlsimport_debuglogs_per_plugin( 'Stopped for ' . $post_id . PHP_EOL );
3889 wp_send_json_success();
3890 }
3891
3892
3893
3894 /**
3895 * AJAX: fetch the MLS metadata (theme schema + field data + enums) for the
3896 * configured theme and cache it in options, marking metadata as populated.
3897 *
3898 * @return void
3899 */
3900 public function mlsimport_saas_get_metadata_function() {
3901 // CSRF.
3902 check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' );
3903 $theme_Start = new ThemeImport();
3904
3905 // GET /clients?theme_id=<id> to retrieve the schema + MLS metadata.
3906 $values = array();
3907 $options = get_option( $this->plugin_name . '_admin_options' );
3908 $url = 'clients?theme_id=' . intval( $options['mlsimport_theme_used'] );
3909
3910 $answer = $theme_Start::globalApiRequestSaas( $url, $values, 'GET' );
3911
3912 // Mark populated and cache the three metadata blobs in options.
3913 update_option( 'mlsimport_mls_metadata_populated', 'yes' );
3914
3915 update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] );
3916 update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] );
3917 update_option( 'mlsimport_mls_metadata_mls_enums', $answer['mls_data']['mls_meta_enums'] );
3918 }
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931 /**
3932 * Append a timestamped message to the cron log file.
3933 *
3934 * Arrays are JSON-encoded; ensures the WP filesystem is initialized before
3935 * writing (append + exclusive lock).
3936 *
3937 * @param string|array $message Message to log.
3938 * @return void
3939 */
3940 public function mlsimport_debuglog_cron( $message ) {
3941 // Encode arrays for readability.
3942 if ( is_array( $message ) ) {
3943 $message = wp_json_encode( $message );
3944 }
3945 // Prefix with a human-readable timestamp.
3946 $message = date( 'F j, Y, g:i a' ) . ' -> ' . $message;
3947 // Ensure WP_Filesystem is available (harmless if already set up).
3948 global $wp_filesystem;
3949 if ( empty( $wp_filesystem ) ) {
3950 require_once ABSPATH . '/wp-admin/includes/file.php';
3951 WP_Filesystem();
3952 }
3953
3954 // Append to the cron log with an exclusive lock.
3955 $path = WP_PLUGIN_DIR . '/mlsimport/logs/cron_logs.log';
3956
3957 file_put_contents( $path, $message, FILE_APPEND | LOCK_EX );
3958 }
3959
3960 }
3961