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

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

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