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

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

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