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

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

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