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
← All changes | admin/class-mlsimport-admin.php +2212 -980 5.8.47.2.1 View file →
@@ -2,9 +2,37 @@
2 2 if ( ! defined( 'ABSPATH' ) ) {
3 3 exit; // Exit if accessed directly
4 4 }
5 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 + */
6 33
34 +
7 35 /**
8 36 * The admin-specific functionality of the plugin.
9 37 *
10 38 * @link http://mlsimport.com/
@@ -43,14 +71,25 @@
43 71 * @access private
44 72 * @var string $version The current version of this plugin.
45 73 */
46 74 private $version;
75 + // Back-reference to the core Mlsimport instance (set externally).
47 76 public $main;
77 + // ThemeImport API client instance (OAuth + all SaaS API calls).
48 78 public $theme_importer;
79 + // Active theme adapter object (e.g. ResidenceClass); stdClass when no theme.
49 80 public $env_data;
81 + // Active MLS provider adapter object; stdClass when none configured.
50 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).
51 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).
52 90 public $field_import;
91 + // Map of supported theme_id => human name (990 standalone, 991-994 themes).
53 92 public $themes;
54 93 /**
55 94 * Initialize the class and set its properties.
56 95 *
@@ -59,11 +98,13 @@
59 98 * @param string $version The version of this plugin.
60 99 */
61 100 public function __construct( $plugin_name, $version ) {
62 101
102 + // Store the plugin slug (used as the option-key prefix) and version.
63 103 $this->plugin_name = $plugin_name;
64 104 $this->version = $version;
65 105
106 + // RESO fields that are enum/lookup-driven and shown on the Import Task form.
66 107 $this->field_import = array(
67 108 'City',
68 109 'CountyOrParish',
69 110 'MlsStatus',
@@ -73,9 +114,12 @@
73 114 'InternetEntireListingDisplayYN',
74 115 'InternetAddressDisplayYN',
75 116 );
76 117
118 + // theme_id => administrator-facing name. Adapter classes are selected by
119 + // Mlsimport_Stored_Listing_Adapter_Factory, never derived from these labels.
77 120 $this->themes = array(
121 + 990 => 'Standalone',
78 122 991 => 'WpResidence',
79 123 992 => 'Houzez',
80 124 993 => 'RealHomes',
81 125 994 => 'Wpestate',
@@ -80,60 +124,117 @@
80 124 993 => 'RealHomes',
81 125 994 => 'Wpestate',
82 126 );
83 127 }
128 +
84 129 /**
130 + * Return the one shared Import Task execution module for this request.
85 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.
86 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.
87 148 *
88 - * Admin Setup
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.
89 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.
90 165 * @since 1.0.0
91 166 */
92 167 public function admin_setup( $plugin_name, $mls_enviroment, $theme_enviroment ) {
93 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.
94 171 $options = get_option( $this->plugin_name . '_admin_options' );
95 - $theme_id = 0;
96 - if ( isset( $options['mlsimport_theme_used'] ) ) {
97 - $theme_id = intval( $options['mlsimport_theme_used'] );
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();
98 187 }
99 - $themes = $this->themes;
100 188
101 - $theme_enviroment = '';
102 - if ( isset( $themes[ $theme_id ] ) ) {
103 - $theme_enviroment = $themes[ $theme_id ];
104 - }
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 + }
105 200
106 - $this->theme_importer = new ThemeImport( $plugin_name );
107 -
108 - $options_api = get_option( $this->plugin_name . '_admin_options' );
109 -
110 - if ( '' !== $theme_enviroment ) {
111 - $classname = str_replace('Wp','',$theme_enviroment ). 'Class';
112 - $this->env_data = new $classname();
113 - } else {
114 - $this->env_data = new stdClass();
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' );
115 220 }
116 -
117 - if ( '' !== $mls_enviroment ) {
118 - $mls_classname = $mls_enviroment . 'Class';
119 -
120 - $this->mls_env_data = new $mls_classname( $this->theme_importer );
121 - } else {
122 - $this->mls_env_data = new stdClass();
123 - }
124 221 }
125 222
126 223 /**
224 + * Whether the current request renders the settings page's Connections tab.
127 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.
128 231 *
129 - *
130 - * Register the stylesheets for the admin area.
131 - *
132 - * @since 1.0.0
232 + * @return bool True when the Connections tab is being rendered.
133 233 */
134 - public function enqueue_styles() {
135 - wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/mlsimport-admin.css', array(), $this->version, 'all' );
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'] ) ) : '' );
136 237 }
137 238
138 239
139 240
@@ -138,31 +239,93 @@
138 239
139 240
140 241
141 242 /**
243 + * Register the JavaScript for the admin area.
142 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.
143 249 *
144 - *
145 - * Register the JavaScript for the admin area.
146 - *
250 + * @param string $hook_suffix Current admin page hook suffix.
147 251 * @since 1.0.0
148 252 */
149 253 public function enqueue_scripts($hook_suffix) {
254 + // jQuery UI autocomplete backs the MLS-name search box.
150 255 wp_enqueue_script( 'jquery-ui-autocomplete' );
256 + // Pull the cached MLS list (used later for the autocomplete bootstrap).
151 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.
152 289 wp_enqueue_script( 'mlsimport-admin', plugin_dir_url( __FILE__ ) . 'js/mlsimport-admin.js', array( 'jquery' ), $this->version, true );
153 290 wp_localize_script(
154 291 'mlsimport-admin',
155 292 'mlsimport_vars',
156 293 array(
157 - 'ajax_url' => admin_url( 'admin-ajax.php' )
294 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
295 + 'provider_families' => $provider_browser_config,
158 296 )
159 297 );
160 298
161 - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
162 - isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' &&
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' &&
163 325 isset($_GET['tab']) && $_GET['tab'] === 'field_options') {
164 - $mlsimport_mls_metadata_populated = get_option( 'mlsimport_mls_metadata_populated', '' );
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 );
165 328 if ( 'yes' !== $mlsimport_mls_metadata_populated ) {
166 329 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
167 330 wp_add_inline_script('mlsimport-admin', $inline_script);
168 331 }
@@ -167,17 +330,145 @@
167 330 wp_add_inline_script('mlsimport-admin', $inline_script);
168 331 }
169 332 }
170 333
171 - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
172 - ( isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && isset($_GET['tab']) && $_GET['tab'] === 'display_options') ||
173 - (isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && !isset($_GET['tab']) ) ) {
174 -
175 - $mls_import_list = mlsimport_saas_request_list();
176 - $inline_script = 'jQuery(document).ready(function($){ var autofill='.wp_kses_post($mls_import_list).';mlsimport_autocomplte_mls_selection(autofill); });';
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(); });';
177 349 wp_add_inline_script('mlsimport-admin', $inline_script);
350 + }
178 351 }
179 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 +
180 471 }
181 472
182 473
183 474
@@ -183,16 +474,18 @@
183 474
184 475
185 476
186 477 /**
478 + * Register the administration menu for this plugin into the WordPress Dashboard menu.
187 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.
188 483 *
189 - *
190 - * Register the administration menu for this plugin into the WordPress Dashboard menu.
191 - *
192 484 * @since 1.0.0
193 485 */
194 486 public function add_plugin_admin_menu() {
487 + // Top-level settings menu (capability: administrator).
195 488 add_menu_page(
196 489 esc_html__( 'MLS Import Settings', 'mlsimport'),
197 490 esc_html__( 'MLS Import Settings', 'mlsimport' ),
198 491 'administrator',
@@ -198,28 +491,222 @@
198 491 'administrator',
199 492 'mlsimport_plugin_options',
200 493 array( $this, 'display_plugin_setup_page' ),
201 494 MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png',
202 - 22
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
203 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 + }
204 537 }
205 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 + }
206 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;
207 568
569 + // The MLS-logo control opens the native WordPress media modal (wp.media).
570 + wp_enqueue_media();
208 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' );
209 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 + }
210 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 + }
211 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 + }
212 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 +
213 683 /**
684 + * Renders the Import History admin page.
214 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.
215 702 *
216 - *
217 - * Add settings action link to the plugins page.
218 - *
703 + * @param array $links Existing plugin action links.
704 + * @return array Links with the Settings link prepended.
219 705 * @since 1.0.0
220 706 */
221 707 public function add_action_links( $links ) {
708 + // Build the Settings link and place it before the default action links.
222 709 $settings_link = array(
223 710 '<a href="' . admin_url( 'admin.php?page=mlsimport_plugin_options' ) . '">' . esc_html__( 'Settings', 'mlsimport') . '</a>',
224 711 );
225 712 return array_merge( $settings_link, $links );
@@ -232,15 +719,16 @@
232 719
233 720
234 721
235 722 /**
723 + * Render the main settings page for this plugin.
236 724 *
725 + * Loads the admin-display partial (whose filename is prefixed with the slug).
237 726 *
238 - * Render the settings page for this plugin.
239 - *
240 727 * @since 1.0.0
241 728 */
242 729 public function display_plugin_setup_page() {
730 + // Delegates the whole page to the slug-prefixed admin-display partial.
243 731 include_once 'partials/' . $this->plugin_name . '-admin-display.php';
244 732 }
245 733
246 734
@@ -248,16 +736,29 @@
248 736
249 737
250 738
251 739 /**
740 + * Sanitize/whitelist the main plugin options on save (register_setting callback).
252 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.
253 745 *
254 - * Validate plugin options fields
255 - *
746 + * @param array $input Raw submitted options.
747 + * @return array Whitelisted, escaped options.
256 748 * @since 1.0.0
257 749 */
258 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 + : '';
259 758
759 + // Whitelist of accepted option keys (value = label/help metadata, unused
760 + // beyond documentation here); anything not listed is dropped on save.
260 761 $valid = array();
261 762 $settings_list = array(
262 763 'auth_username' => array(
263 764 'name' => esc_html__( 'Api auth_username ', 'mlsimport' ),
@@ -287,13 +788,13 @@
287 788 'name' => esc_html__( 'title_format', 'mlsimport' ),
288 789 'details' => 'to be added',
289 790 ),
290 791 'mlsimport_username' => array(
291 - 'name' => esc_html__( 'MLSImport Username', 'mlsimport' ),
792 + 'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ),
292 793 'details' => 'to be added',
293 794 ),
294 795 'mlsimport_password' => array(
295 - 'name' => esc_html__( 'MLSImport Password', 'mlsimport' ),
796 + 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ),
296 797 'details' => 'to be added',
297 798 ),
298 799 'mlsimport_mls_name' => array(
299 800 'name' => esc_html__( 'MLSImport Name', 'mlsimport' ),
@@ -308,18 +809,28 @@
308 809 'name' => esc_html__( 'MLSImport Tresle Client id', 'mlsimport' ),
309 810 'details' => 'to be added',
310 811 ),
311 812
312 - 'mlsimport_tresle_client_secret' => array(
313 - 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
314 - 'details' => 'to be added',
315 - ),
813 + 'mlsimport_tresle_client_secret' => array(
814 + 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
815 + 'details' => 'to be added',
816 + ),
316 817
317 - 'mlsimport_rapattoni_client_id' => array(
318 - 'name' => esc_html__( 'MLSImport Rapattoni Client id','mlsimport'),
319 - 'details' => 'to be added',
320 - ),
818 + 'mlsimport_connectmls_username' => array(
819 + 'name' => esc_html__( 'MLSImport ConnectMLS Username', 'mlsimport' ),
820 + 'details' => 'to be added',
821 + ),
321 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 +
322 833 'mlsimport_rapattoni_client_secret' => array(
323 834 'name' => esc_html__( 'MLSImport Rapattoni Secret', 'mlsimport' ),
324 835 'details' => 'to be added',
325 836 ),
@@ -342,9 +853,27 @@
342 853 'mlsimport_paragon_client_secret' => array(
343 854 'name' => esc_html__( 'MLSImport Paragon Secret', 'mlsimport' ),
344 855 'details' => 'to be added',
345 856 ),
857 + 'mlsimport_realtorca_client_id' => array(
858 + 'name' => esc_html__( 'MLSImport Realtor.ca Client id','mlsimport' ),
859 + 'details' => 'to be added',
860 + ),
346 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 +
347 876 'mlsimport_theme_used' => array(
348 877 'name' => esc_html__( 'Your Wordpress Theme', 'mlsimport' ),
349 878 'details' => 'to be added',
350 879 ),
@@ -357,15 +886,37 @@
357 886 'details' => 'to be added',
358 887 ),
359 888 );
360 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.
361 894 foreach ( $settings_list as $key => $setting ) {
362 - $valid[ $key ] = ( isset( $input[ $key ] ) && ! empty( $input[ $key ] ) ) ? esc_attr( $input[ $key ] ) : '';
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 + }
363 902 }
364 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.
365 915 delete_option( 'mlsimport_connection_test' );
366 - delete_option( 'mlsimport_mls_metadata_populated' );
916 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
367 917
918 + // Reset cached encoding and drop cached token/schema transients.
368 919 update_option( 'mlsimport_encoding_array', '' );
369 920 delete_transient( 'mlsimport_token_request' );
370 921 delete_transient( 'mlsimport_schema' );
371 922 delete_transient( 'mlsimport_plugin_data_schema' );
@@ -378,49 +929,24 @@
378 929
379 930
380 931
381 932 /**
933 + * Validate the MLS-sync option group on save (register_setting callback).
382 934 *
935 + * Copies a fixed whitelist of sync/import parameter keys straight through.
383 936 *
384 - * Validate admin fields
385 - *
937 + * @param array $input Raw submitted sync settings.
938 + * @return array Whitelisted sync settings.
386 939 * @since 1.0.0
387 940 */
388 - public function validate_admin_fields_select( $input ) {
389 - $valid = array();
390 -
391 - $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' );
392 - $metadata_api_call = json_decode( $mlsimport_mls_metadata_mls_data, true );
393 -
394 - foreach ( $metadata_api_call as $key => $value ) {
395 - if ( isset( $input['mls-fields'][ $key ] ) ) {
396 - $valid['mls-fields'][ $key ] = esc_attr( $input['mls-fields'][ $key ] );
397 - }
398 -
399 - if ( isset( $input['mls-fields-admin'][ $key ] ) ) {
400 - $valid['mls-fields-admin'][ $key ] = esc_attr( $input['mls-fields-admin'][ $key ] );
401 - $valid['mls-fields-label'][ $key ] = esc_attr( $input['mls-fields-label'][ $key ] );
402 - $valid['mls-fields-map-postmeta'][ $key ] = esc_attr( $input['mls-fields-map-postmeta'][ $key ] );
403 - $valid['mls-fields-map-taxonomy'][ $key ] = esc_attr( $input['mls-fields-map-taxonomy'][ $key ] );
404 -
405 - }
406 - }
407 - $valid['mls-fields-admin']['force_rand'] = esc_attr( $input['mls-fields-admin']['force_rand'] );
408 - return $valid;
409 - }
410 -
411 - /**
412 - *
413 - *
414 - * Validate Mls Sync fields
415 - *
416 - * @since 1.0.0
417 - */
418 941 public function validate_admin_mls_sync( $input ) {
419 942 $valid = array();
420 943
421 - $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',
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',
422 947 'StandardStatus_delete', 'StandardStatus_delete_check', 'InternetEntireListingDisplayYN', 'InternetAddressDisplayYN' );
948 + // Pass each whitelisted key through unchanged.
423 949 foreach ( $field_import as $key ) {
424 950 $valid[ $key ] = $input[ $key ];
425 951 }
426 952
@@ -427,110 +953,40 @@
427 953 return $valid;
428 954 }
429 955
430 956
431 - /**
432 - *
433 - *
434 - * Validate Administrative options
435 - *
436 - * @since 1.0.0
437 - */
438 - public function validate_administrative_options( $input ) {
439 957
440 - $valid = array();
441 -
442 - $field_import = array( 'import' );
443 - foreach ( $field_import as $key ) {
444 - $valid[ $key ] = $input[ $key ];
445 - }
446 -
447 - return $valid;
448 - }
449 -
450 958 /**
451 - *
452 - *
453 - *
454 - * Validate Import Options fields
455 - *
456 - * @since 1.0.0
959 + * Register all plugin option groups with the Settings API and bind each to
960 + * its validation callback. Hooked on admin_init.
457 961 */
458 - public function validate_admin_import_options( $input ) {
459 - $valid = array();
460 -
461 - $field_import = array( 'import_number' );
462 - foreach ( $field_import as $key ) {
463 - $valid[ $key ] = intval( $input[ $key ] );
464 - }
465 -
466 - if ( isset( $input['import'] ) && '' !== $input['import'] ) {
467 - $decode = json_decode( $input['import'] );
468 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
469 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
470 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
471 - update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] );
472 - }
473 -
474 - return $valid;
475 - }
476 -
477 -
478 -
479 -
480 -
481 -
482 - /**
483 - *
484 - *
485 - * plugin options update
486 - */
487 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.
488 965 register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) );
489 - register_setting( $this->plugin_name . '_admin_fields_select', $this->plugin_name . '_admin_fields_select', array( $this, 'validate_admin_fields_select' ) );
490 966 register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) );
491 - register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) );
492 - register_setting( $this->plugin_name . '_administrative_options', $this->plugin_name . '_administrative_options', array( $this, 'validate_administrative_options' ) );
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.
493 969 }
494 970
495 -
496 -
497 971 /**
498 - *
499 - *
500 - *
501 - *
502 - *
503 - *
504 - *
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.
505 974 */
506 - public function update_option_mlsimport_administrative_options() {
507 - $import = get_option( 'mlsimport_administrative_options' );
508 - if ( '' !== $import ) {
509 - $decode = json_decode( $import['import'], true );
510 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
511 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
512 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
513 - }
514 - }
515 -
516 - /**
517 - *
518 - *
519 - * plugin options update
520 - */
521 975 public function update_option_mlsimport_admin_fields_select() {
522 976
977 + // Delegate to the theme adapter to sync its custom fields.
523 978 $this->env_data->enviroment_custom_fields( $this->plugin_name );
524 979 }
525 980
526 981
527 982 /**
983 + * Register the "Hidden Fields" metabox on the theme's property post type.
528 984 *
529 - *
530 - * plugin options update
985 + * Only added when the theme adapter exposes get_property_post_type().
531 986 */
532 987 public function mlsimport_meta_options() {
988 + // Add the metabox to whatever post type the active theme uses for listings.
533 989 if ( method_exists( $this->env_data, 'get_property_post_type' ) ) {
534 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' );
535 991 }
536 992 }
@@ -535,67 +991,80 @@
535 991 }
536 992 }
537 993
538 994 /**
995 + * Render the "Hidden Fields" metabox for a single property post.
539 996 *
540 - *
541 - * plugin options update
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.
542 1000 */
543 1001 public function mlsimport_hidden_fields() {
544 1002 global $post;
545 1003
546 - $options = get_option( $this->plugin_name . '_admin_fields_select' );
1004 + // The active projection keeps disappeared MLS fields out of the metabox.
1005 + $options = mlsimport_active_field_configuration();
547 1006
1007 + // Which Import Task created / last updated this property, and its RESO key.
548 1008 $MLSimport_item_inserted = get_post_meta( $post->ID, 'MLSimport_item_inserted', true );
549 1009 $MLSimport_item_updated = get_post_meta( $post->ID, 'MLSimport_item_updated', true );
550 - $listing_key = get_post_meta( $post->ID, 'ListingKey', true );
551 - $mlsImportItemStatusDelete = get_post_meta($post->ID, 'mlsImportItemStatusDelete', true);
1010 + $listing_key = get_post_meta( $post->ID, '_mlsimport_listing_key', true );
552 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;
553 1016
554 -
555 1017 // Check if the ListingKey exists
556 1018 if ( !empty( $listing_key ) ) {
557 - echo 'ListingKey: ' . $listing_key . '<br>';
1019 + echo 'ListingKey: ' . esc_html( $listing_key ) . '<br>';
558 1020 }
559 1021
560 1022 // Check if MLSimport_item_inserted exists
561 1023 if ( !empty( $MLSimport_item_inserted ) ) {
562 - echo 'Added via MLS item id: ' . $MLSimport_item_inserted . ' - ' . get_the_title( $MLSimport_item_inserted ) . '<br>';
1024 + echo 'Added via MLS item id: ' . esc_html( $MLSimport_item_inserted ) . ' - ' . esc_html( get_the_title( $MLSimport_item_inserted ) ) . '<br>';
563 1025 }
564 1026
565 1027 // Check if MLSimport_item_updated exists
566 1028 if ( !empty( $MLSimport_item_updated ) ) {
567 - echo 'Updated via MLS item id: ' . $MLSimport_item_updated . ' - ' . get_the_title( $MLSimport_item_updated ) . '<br>';
1029 + echo 'Updated via MLS item id: ' . esc_html( $MLSimport_item_updated ) . ' - ' . esc_html( get_the_title( $MLSimport_item_updated ) ) . '<br>';
568 1030 }
569 1031
570 -
571 - if(!empty($mlsImportItemStatusDelete)) {
572 - if(is_array($mlsImportItemStatusDelete)) {
573 -
574 - echo 'Do not delete if status: ' . implode(',' ,$mlsImportItemStatusDelete) . '<br>';
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>';
575 1036 } else {
576 -
577 - echo 'Do not delete if status: ' . esc_html($mlsImportItemStatusDelete) . '<br>';
578 -
1037 + echo 'Protected statuses: ' . esc_html($mlsImportItemStatusProtect) . '<br>';
579 1038 }
580 -
581 1039 }
582 1040
583 - foreach ( $options['mls-fields-admin'] as $key => $value ) {
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).
584 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;
585 1047 if ( isset( $options['mls-fields-label'][ $key ] ) && '' !== $options['mls-fields-label'][ $key ] ) {
586 - $key = $options['mls-fields-label'][ $key ];
1048 + $display_label = $options['mls-fields-label'][ $key ];
587 1049 }
588 1050
589 - if ( 'ListingKey' !== $key ) {
590 - $meta_key = strtolower( $key );
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 );
591 1057 } else {
592 - $meta_key = $key;
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 );
593 1062 }
594 1063 ?>
595 1064
596 - <strong><?php echo esc_html($key);?>:</strong>
597 - <?php echo esc_html( get_post_meta( $post->ID, $meta_key, true ) ); ?> </br>
1065 + <strong><?php echo esc_html($display_label);?>:</strong>
1066 + <?php echo esc_html( $field_value ); ?> </br>
598 1067 <?php
599 1068 }
600 1069 }
601 1070 ?>
@@ -600,9 +1069,10 @@
600 1069 }
601 1070 ?>
602 1071
603 1072 <h2 style="font-weight:bold;padding-left:0px;">Mls Import History</h2>
604 - <?php
1073 + <?php
1074 + // Property change history (only populated when history logging is enabled).
605 1075 $meta = get_post_meta( $post->ID, 'mlsimport_property_history', true );
606 1076 if ( '' === trim( $meta ) ) { ?>
607 1077 <strong>Property history is blank - you can enable it in Settings/ Tools page </strong>
608 1078 <?php
@@ -614,14 +1084,17 @@
614 1084
615 1085
616 1086
617 1087 /**
618 - * delete cache
1088 + * AJAX (Tools page): clear all MLSImport caches/transients and the
1089 + * metadata-populated flag, forcing the next request to re-fetch everything.
619 1090 */
620 - function mlsimport_delete_cache() {
1091 + function mlsimport_delete_cache() {
621 1092
1093 + // CSRF: Tools-page nonce.
622 1094 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
623 1095
1096 + // Drop every cached token/metadata/schema transient.
624 1097 delete_transient( 'mlsimport_token_request' );
625 1098 delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
626 1099 delete_transient( 'mls_import_meta_enums' );
627 1100 delete_transient( 'mls_import_meta' );
@@ -628,268 +1101,532 @@
628 1101 delete_transient( 'mlsimport_plugin_data_schema' );
629 1102 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
630 1103 delete_transient( 'mlsimport_saas_token' );
631 1104
632 - delete_option( 'mlsimport_mls_metadata_populated' );
633 - die( 'deleted' );
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 );
634 1161 }
635 1162
636 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.
637 1167 *
638 - *
639 - *
640 - *
641 - * delete properties
1168 + * @return void Emits a JSON success payload {deleted,remaining,total,done}.
642 1169 */
643 1170 function mlsimport_delete_properties() {
644 - $error = false;
645 1171 global $mlsimport;
646 1172
1173 + // CSRF + capability.
647 1174 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
648 1175
1176 + if ( ! current_user_can( 'administrator' ) ) {
1177 + wp_send_json_error( 'Unauthorized' );
1178 + }
649 1179
650 - if ( current_user_can( 'administrator' ) ) :
651 - $mlsimport_delete_category = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) ) ;
652 - $mlsimport_delete_category_term = sanitize_title( sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category_term']) ) );
653 - $mlsimport_delete_timeout = intval( $_POST['mlsimport_delete_timeout'] );
1180 + // Selected taxonomy and its chosen term slugs.
1181 + $taxonomy = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) );
1182 + $terms = array();
654 1183
655 - if ( '' === $mlsimport_delete_category ) {
656 - $error_message = esc_html__('Category cannot be blank','mlsimport');
657 - $error = true;
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 ) );
658 1188 }
1189 + }
659 1190
660 - if ( '' === $mlsimport_delete_category_term ) {
661 - $error_message = esc_html__('Category Term cannot be blank','mlsimport');
662 - $error = true;
663 - }
1191 + // Require a taxonomy.
1192 + if ( '' === $taxonomy ) {
1193 + wp_send_json_error( esc_html__( 'Please select a taxonomy', 'mlsimport' ) );
1194 + }
664 1195
665 - $category = get_term_by( 'slug', $mlsimport_delete_category_term, $mlsimport_delete_category );
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 + }
666 1200
667 - if ( $error ) {
668 - print wp_json_encode(
669 - array(
670 - 'message' => esc_html($error_message),
671 - )
672 - );
673 - } else {
674 - $mlsimport_delete_category_term_array = array();
1201 + // Query one page of property IDs matching the term selection.
1202 + $post_type = $mlsimport->admin->env_data->get_property_post_type();
675 1203
676 - $mlsimport_delete_category_term_array[] = $mlsimport_delete_category_term;
677 - $tax_array = array(
678 - 'taxonomy' => $mlsimport_delete_category,
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,
679 1211 'field' => 'slug',
680 - 'terms' => $mlsimport_delete_category_term_array,
681 - );
1212 + 'terms' => $terms,
1213 + ),
1214 + ),
1215 + 'fields' => 'ids',
1216 + );
682 1217
683 - $args = array(
684 - 'post_type' => array( 'estate_property', 'property' ),
685 - 'post_status' => 'any',
686 - 'paged' => 1,
687 - 'posts_per_page' => -1,
688 - 'tax_query' => array(
689 - $tax_array,
690 - ),
691 - 'fields' => 'ids',
692 - );
1218 + $prop_selection = new WP_Query( $args );
1219 + $deleted = 0;
693 1220
694 - $prop_selection = new WP_Query( $args );
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 + }
695 1226
696 - foreach ( $prop_selection->posts as $key => $delete_get_id ) {
697 - if ( 0 !== $mlsimport_delete_timeout ) {
698 - set_timeout( $mlsimport_delete_timeout );
699 - }
1227 + // Compute how many still match after this batch; done when none remain.
1228 + $remaining = $prop_selection->found_posts - $deleted;
1229 + $done = ( $remaining <= 0 );
700 1230
701 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql( $delete_get_id, ' delete from tools ' );
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 );
702 1239 }
1240 + }
1241 + }
703 1242
704 - wp_update_term_count_now( array( $category->term_id ), $mlsimport_delete_category );
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 + }
705 1251
706 - print wp_json_encode(
707 - array(
708 - '$category' => $category->term_id,
709 - 'arguments' => $args,
710 - 'posts' => $prop_selection->posts,
711 - 'found' => $prop_selection->found_posts,
712 - 'message' => 'Done...',
713 - )
714 - );
715 - }
716 - endif;
717 - die();
718 - }
719 1252
720 1253
721 1254
722 1255
1256 +
1257 +
1258 +
723 1259 /**
1260 + * Convert a PHP shorthand byte value (e.g. "256M", "1G", "-1") to bytes.
724 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.
725 1294 *
726 - * Testing enviroment variables
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.
727 1300 */
728 1301 public function mlsimport_saas_setting_up() {
729 - if (intval(WP_MEMORY_LIMIT) < 256): ?>
730 - <div class="mlsimport_warning long_warning">
731 - <strong>WordPress Memory Limit</strong> is set to <strong><?php echo esc_html(WP_MEMORY_LIMIT); ?></strong>. Allocated Memory should be at least <strong>256MB</strong>. Please refer to: <a href="https://wordpress.org/support/article/editing-wp-config-php/#increasing-memory-allocated-to-php" target="_blank">Increasing memory allocated to PHP</a>
732 - </div>
733 - <?php endif; ?>
734 -
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>
735 1343 <?php
736 - $max_input_vars = ini_get('max_input_vars');
737 - if ($max_input_vars < 2000):
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 ) {
738 1350 ?>
739 - <div class="mlsimport_warning long_warning">Your <strong>max_input_vars</strong> setting in php is set to <strong><?php echo esc_html($max_input_vars); ?></strong>. When importing real estate listings from an MLS, we work with a lot of data that needs to be saved. Please increase this value to at least <strong>2000</strong> or higher in order to save all details. Please refer to <a href="https://themezly.com/docs/how-to-increase-the-max-input-vars-limit/" target="_blank">How to Increase the Max Input Vars Limit</a></div>
740 - <?php endif; ?>
741 -
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 +
742 1364 <?php
743 - $max_time = ini_get('max_execution_time');
744 - if ($max_time < 600 && 0 !== $max_time):
745 - ?>
746 - <div class="mlsimport_warning long_warning">Your <strong>max_execution_time</strong> setting in php is set to <strong><?php echo esc_html($max_time); ?></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>.</div>
747 - <?php endif;
748 -
1365 + }
749 1366 }
750 1367
751 - /**
752 - * Check if token validates with MLS
753 - *
754 - * @since 4.0.1
755 - * returns token fron mlsimport
756 - */
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 + */
757 1378 public function mlsimport_saas_check_mls_connection() {
758 1379
759 - $values = array();
760 - $options = get_option( $this->plugin_name . '_admin_options' );
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 );
761 1389
762 - $mls_id = '';
763 - if ( isset( $options['mlsimport_mls_name'] ) ) {
764 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
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 + );
765 1398 }
766 1399
767 - $mls_token = '';
768 - if ( isset( $options['mlsimport_mls_name'] ) ) {
769 - $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
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 + );
770 1408 }
1409 + $values = $payload_result['payload'];
771 1410
772 - $mlsimport_tresle_client_id = '';
773 - if ( isset( $options['mlsimport_tresle_client_id'] ) ) {
774 - $mlsimport_tresle_client_id = sanitize_text_field( trim( $options['mlsimport_tresle_client_id'] ) );
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 );
775 1422 }
776 1423
777 - $mlsimport_tresle_client_secret = '';
778 - if ( isset( $options['mlsimport_tresle_client_secret'] ) ) {
779 - $mlsimport_tresle_client_secret = sanitize_text_field( trim( $options['mlsimport_tresle_client_secret'] ) );
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 );
780 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 + }
781 1436
782 - // rapattoni data
783 - $mlsimport_rapattoni_client_id = '';
784 - if ( isset( $options['mlsimport_rapattoni_client_id'] ) ) {
785 - $mlsimport_rapattoni_client_id = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_id'] ) );
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' );
786 1450 }
787 - $mlsimport_rapattoni_client_secret = '';
788 - if ( isset( $options['mlsimport_rapattoni_client_secret'] ) ) {
789 - $mlsimport_rapattoni_client_secret = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_secret'] ) );
790 - }
791 1451
792 - $mlsimport_rapattoni_username = '';
793 - if ( isset( $options['mlsimport_rapattoni_username'] ) ) {
794 - $mlsimport_rapattoni_username = sanitize_text_field( trim( $options['mlsimport_rapattoni_username'] ) );
795 - }
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 );
796 1456
797 - $mlsimport_rapattoni_password = '';
798 - if ( isset( $options['mlsimport_rapattoni_password'] ) ) {
799 - $mlsimport_rapattoni_password = sanitize_text_field( trim( $options['mlsimport_rapattoni_password'] ) );
800 - }
1457 + return $answer;
1458 + }
801 1459
802 - // paragon data
803 - $mlsimport_paragon_client_id = '';
804 - if ( isset( $options['mlsimport_paragon_client_id'] ) ) {
805 - $mlsimport_paragon_client_id = sanitize_text_field( trim( $options['mlsimport_paragon_client_id'] ) );
806 - }
807 - $mlsimport_paragon_client_secret = '';
808 - if ( isset( $options['mlsimport_paragon_client_secret'] ) ) {
809 - $mlsimport_paragon_client_secret = sanitize_text_field( trim( $options['mlsimport_paragon_client_secret'] ) );
810 - }
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 + }
811 1473
812 - if ( trim( $mls_token ) === '' ) {
813 - if ( intval( $mls_id ) > 900 && intval( $mls_id ) < 3000 ) {
814 - if ( trim( $mlsimport_tresle_client_id ) === '' || trim( $mlsimport_tresle_client_secret ) === '' ) {
815 - return;
816 - }
817 - } elseif ( intval( $mls_id ) >= 5000 && intval( $mls_id ) < 6000 ) {
818 - if (
819 - trim( $mlsimport_rapattoni_client_id ) === '' ||
820 - trim( $mlsimport_rapattoni_client_secret ) === '' ||
821 - trim( $mlsimport_rapattoni_username ) === '' ||
822 - trim( $mlsimport_rapattoni_password ) === ''
823 - ) {
824 - return;
825 - }
826 - } elseif ( intval( $mls_id ) >= 6000 ) {
827 - if (
828 - trim( $mlsimport_paragon_client_id ) === '' ||
829 - trim( $mlsimport_paragon_client_secret ) === ''
830 - ) {
831 - return;
832 - }
833 - }
834 - }
1474 + $input = array(
1475 + 'reason' => sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) ),
1476 + 'details' => sanitize_textarea_field( wp_unslash( $_POST['details'] ?? '' ) ),
1477 + );
835 1478
836 - $values['mls_token'] = $mls_token;
837 - $values['mls_id'] = $mls_id;
838 - $values['mlsimport_tresle_client_id'] = $mlsimport_tresle_client_id;
839 - $values['mlsimport_tresle_client_secret'] = $mlsimport_tresle_client_secret;
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 + }
840 1489
841 - $values['mlsimport_rapattoni_client_id'] = $mlsimport_rapattoni_client_id;
842 - $values['mlsimport_rapattoni_client_secret'] = $mlsimport_rapattoni_client_secret;
843 - $values['mlsimport_rapattoni_username'] = $mlsimport_rapattoni_username;
844 - $values['mlsimport_rapattoni_password'] = $mlsimport_rapattoni_password;
1490 + wp_send_json_success();
1491 + }
845 1492
846 - $values['mlsimport_paragon_client_id'] = $mlsimport_paragon_client_id;
847 - $values['mlsimport_paragon_client_secret'] = $mlsimport_paragon_client_secret;
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 + }
848 1501
849 - $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
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 + }
850 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 + }
851 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 + }
852 1543
1544 + $count = (int) get_option( 'mlsimport_deactivation_count', 0 ) + 1;
1545 + update_option( 'mlsimport_deactivation_count', $count );
853 1546
854 - if ( isset( $answer['succes'] ) && true === $answer['succes'] ) {
855 - if ( isset( $answer['tested'] ) && true === $answer['tested'] ) {
856 - update_option( 'mlsimport_connection_test', 'yes' );
857 - } else {
858 - delete_option( 'mlsimport_connection_test' );
859 - delete_option( 'mlsimport_mls_metadata_populated' );
860 - }
861 - } else {
862 - delete_option( 'mlsimport_connection_test' );
863 - delete_option( 'mlsimport_mls_metadata_populated' );
864 - }
1547 + $reason = (string) ( $input['reason'] ?? '' );
1548 + $options = $this->get_exit_survey_options();
865 1549
866 - return $answer;
867 - }
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 + }
868 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 + }
869 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 + }
870 1602
871 1603
872 1604
873 1605
1606 +
1607 +
874 1608 /**
875 - * Request auth token from mlsimport.net
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.
876 1611 *
877 1612 * @since 4.0.1
878 - * returns token fron mlsimport
1613 + * @return string|array The token string, or the raw answer/'' on failure.
879 1614 */
880 1615 public function mlsimport_saas_get_mls_api_token_from_transient() {
881 1616
1617 + // Prefer the cached token.
882 1618 $token = get_transient( 'mlsimport_saas_token' );
883 1619
1620 + // Cache miss/empty: request a new token and cache it on success.
884 1621 if ( false === $token || '' === $token ) {
885 1622 $token_json_answer = $this->mlsimport_saas_get_mls_api_token();
886 -
887 1623
888 - if ( isset( $token_json_answer['succes'] ) && true === $token_json_answer['succes'] ) {
1624 + if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) {
889 1625 $token = $token_json_answer['token'];
890 1626
891 - set_transient( 'mlsimport_saas_token', $token, 6 * 60 * 60 );
1627 + // 3500s < the token's 1h life, leaving headroom before expiry.
1628 + set_transient( 'mlsimport_saas_token', $token, 3500 );
892 1629 }
893 1630 }
894 1631
895 1632 return $token;
@@ -896,17 +1633,25 @@
896 1633 }
897 1634
898 1635
899 1636 /**
900 - * call for token
1637 + * Request a fresh SaaS API token using the stored account username/password.
901 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 + *
902 1643 * @since 4.0.1
903 - * returns token fron mlsimport
1644 + * @return array|string The 'token' API response, or '' when unconfigured.
904 1645 */
905 1646 protected function mlsimport_saas_get_mls_api_token() {
906 1647 $values = array();
907 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', '' );
908 1652
1653 +
909 1654 $username = '';
910 1655 if ( isset( $options['mlsimport_username'] ) ) {
911 1656 $username = sanitize_text_field( trim( $options['mlsimport_username'] ) );
912 1657 }
@@ -912,9 +1657,11 @@
912 1657 }
913 1658
914 1659 $password = '';
915 1660 if ( isset( $options['mlsimport_password'] ) ) {
916 - $password = sanitize_text_field( trim( $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'] );
917 1664 }
918 1665 $mls_name = '';
919 1666 if ( isset( $options['mlsimport_mls_name'] ) ) {
920 1667 $mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
@@ -924,19 +1671,48 @@
924 1671 if ( isset( $options['mlsimport_mls_token'] ) ) {
925 1672 $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
926 1673 }
927 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.
928 1700 $values['username'] = $username;
929 1701 $values['password'] = $password;
930 1702
1703 + // No account credentials -> nothing to request.
931 1704 if ( '' === $username || '' === $password ) {
932 1705 return '';
933 1706 }
934 1707
1708 + // POST to the SaaS 'token' endpoint and return its response.
935 1709 $theme_Start = new ThemeImport();
936 1710 $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' );
937 1711
938 -
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 );
939 1715
940 1716 return $answer;
941 1717 }
942 1718
@@ -945,13 +1721,14 @@
945 1721
946 1722
947 1723
948 1724 /**
949 - * save meta options
1725 + * Register the "Set Import data" metabox on the mlsimport_item post type.
950 1726 *
951 1727 * @since 3.0.1
952 1728 */
953 1729 public function mlsimport_item_product_metaboxes() {
1730 + // The metabox renders the import-parameter form for an Import Task.
954 1731 add_meta_box( 'mlsimport_item_metaboxes-sectionid', __( 'Set Import data', 'mlsimport' ), array( $this, 'mlsimport_saas_display_meta_options' ), 'mlsimport_item', 'normal', 'default' );
955 1732 }
956 1733
957 1734
@@ -956,29 +1733,61 @@
956 1733
957 1734
958 1735
959 1736 /**
1737 + * Save the Import Task metabox fields to post meta (save_post callback).
960 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.
961 1743 *
962 - *
963 - * save meta options
964 - *
1744 + * @param int $post_id Post being saved.
1745 + * @param WP_Post $post Post object.
965 1746 * @since 3.0.1
966 1747 */
967 1748 public function mlsimport_item_product_save_metaboxes( $post_id, $post ) {
968 1749
1750 + // Guard against non-post contexts.
969 1751 if ( ! is_object( $post ) || ! isset( $post->post_type ) ) {
970 1752 return;
971 1753 }
972 1754
1755 + // Only handle Import Task posts.
973 1756 if ( 'mlsimport_item' !== $post->post_type ) {
974 1757 return;
975 1758 }
976 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.
977 1785 $allowed_keys = array(
978 1786 'mlsimport_item_how_many',
979 1787 'mlsimport_item_title_format',
980 1788 'mlsimport_item_agent',
1789 + 'mlsimport_item_use_mls_agent',
981 1790 'mlsimport_item_property_status',
982 1791 'mlsimport_item_property_user',
983 1792 'mlsimport_item_min_price',
984 1793 'mlsimport_item_max_price',
@@ -994,31 +1803,37 @@
994 1803 'mlsimport_item_propertytype_check',
995 1804 'mlsimport_item_propertytype',
996 1805 'mlsimport_item_standardstatus_check',
997 1806 'mlsimport_item_standardstatus',
998 - 'mlsimport_item_standardstatusdelete_check',
999 - 'mlsimport_item_standardstatusdelete',
1807 + 'mlsimport_item_standardstatusprotect_check',
1808 + 'mlsimport_item_standardstatusprotect',
1000 1809
1001 1810 'mlsimport_item_internetentirelistingdisplayyn',
1002 1811 'mlsimport_item_internetaddressdisplayyn',
1003 1812 'mlsimport_item_stat_cron',
1004 - 'mlsimport_item_listagentkey',
1005 - 'mlsimport_item_listagentmlsid',
1006 - 'mlsimport_item_listofficekey',
1007 - 'mlsimport_item_postalcode',
1008 - 'mlsimport_item_listofficemlsid',
1009 - 'mlsimport_item_listingid',
1010 - 'mlsimport_item_extracity',
1011 - 'mlsimport_item_extracounty',
1012 - 'mlsimport_item_exclude_listofficemlsid',
1013 - 'mlsimport_item_exclude_listofficekey',
1014 - 'mlsimport_item_exclude_listagentmlsid',
1015 - 'mlsimport_item_exclude_listagentkey',
1016 - );
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 + );
1017 1831
1018 1832
1019 1833
1020 1834
1835 + // Store each posted key (recursively sanitized; key sanitized too).
1021 1836 foreach ( $allowed_keys as $key => $key_value ) {
1022 1837 if( isset($_POST[$key_value]) ){
1023 1838 $postmeta = mlsimport_sanitize_multi_dimensional_array ( $_POST[$key_value] ) ;
1024 1839 update_post_meta( $post_id, sanitize_key( $key_value ), $postmeta );
@@ -1025,19 +1840,27 @@
1025 1840 }
1026 1841
1027 1842 }
1028 1843
1844 + // Keys that must be reset to '' when omitted from the POST (cleared).
1029 1845 $blank_keys = array(
1846 + 'mlsimport_item_use_mls_agent',
1030 1847 'mlsimport_item_standardstatus',
1848 + 'mlsimport_item_standardstatusprotect',
1031 1849 'mlsimport_item_city',
1032 1850 'mlsimport_item_countyorparish',
1033 1851 'mlsimport_item_propertysubtype',
1034 - 'mlsimport_item_propertytype',
1035 - 'mlsimport_item_standardstatus',
1036 - 'mlsimport_item_listingid',
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',
1037 1859
1038 1860 );
1039 1861
1862 + // Reset any whitelisted-blank key that was not submitted this save.
1040 1863 foreach ( $blank_keys as $key ) {
1041 1864 if ( ! isset( $_POST[ $key ] ) ) {
1042 1865 update_post_meta( $post_id, $key, '' );
1043 1866 }
@@ -1047,51 +1870,104 @@
1047 1870 }
1048 1871
1049 1872
1050 1873 /**
1051 - * Display Meta Options
1874 + * Render the Import Task metabox content.
1052 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 + *
1053 1886 * @param WP_Post $post The post object.
1054 1887 */
1055 - public function mlsimport_saas_display_meta_options($post) {
1056 - wp_nonce_field(plugin_basename(__FILE__), 'estate_agent_noncename');
1057 - global $mlsimport;
1058 -
1059 - $postId = $post->ID;
1060 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
1061 -
1062 - $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1063 - $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1064 - $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1065 - $status = get_option('mlsimport_force_stop_' . $postId);
1066 - $fieldImport = $this->mlsimport_saas_return_mls_fields();
1067 - $options = get_option('mlsimport_admin_options');
1068 - $mlsimportMlsId = isset($options['mlsimport_mls_name']) && $options['mlsimport_mls_name'] !== ''
1069 - ? intval($options['mlsimport_mls_name'])
1070 - : 0;
1071 -
1072 - $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1073 - if (isset($mlsRequest['success']) && !$mlsRequest['success']) {
1074 - echo '<div class="mlsimport_warning">' . esc_html($mlsRequest['message']) . '</div>';
1075 - }
1076 -
1077 - $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none';
1078 - if ($foundItems === 'none') {
1079 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
1080 - esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport');
1081 - }
1082 -
1083 - echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport);
1084 - }
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;
1085 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();
1086 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 + }
1087 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 + }
1088 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 +
1089 1964 /**
1090 1965 * Generate Meta Options HTML
1091 1966 *
1092 1967 * @param int $postId The post ID.
1093 - * @param int $foundItems The number of found items.
1968 + * @param int|null $foundItems The number of found items, or null when the
1969 + * count request failed and no count is known.
1094 1970 * @param string $lastDate The last date checked.
1095 1971 * @param string $mlsimportItemHowMany How many items to import.
1096 1972 * @param string $mlsimportItemStatCron The status of the cron job.
1097 1973 * @param int $mlsimportMlsId The MLS import ID.
@@ -1097,11 +1973,40 @@
1097 1973 * @param int $mlsimportMlsId The MLS import ID.
1098 1974 * @param array $fieldImport The fields to import.
1099 1975 * @return string The generated HTML.
1100 1976 */
1101 - private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport) {
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.
1102 1981 ob_start();
1103 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 +
1104 2009 ?>
1105 2010 <div class="mlsimport_item_search_url" style="display:none;"><?php echo esc_html__('Last date/time we check :', 'mlsimport') . ' ' . esc_html($lastDate); ?></div>
1106 2011 <ul>
1107 2012 <li>1. Set the import parameters.</li>
@@ -1111,22 +2016,64 @@
1111 2016 </ul>
1112 2017
1113 2018 <?php if (is_numeric($foundItems) && $foundItems >= 500): ?>
1114 2019 <div class="mlsimport_notification">
1115 - <?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 items.', 'mlsimport'); ?>
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'); ?>
1116 2021 </div>
1117 2022 <?php endif; ?>
1118 2023
1119 2024 <div class="mlsimport_import_no">
1120 - <?php esc_html_e('We found', 'mlsimport'); ?>
1121 - <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.
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; ?>
1122 2032 </div>
1123 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 +
1124 2070 <fieldset class="mlsimport-fieldset">
1125 2071 <label class="mlsimport-label" for="mlsimport_item_how_many">
1126 2072 <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?>
1127 2073 </label>
1128 - <input type="text" id="mlsimport_item_how_many" name="mlsimport_item_how_many" value="<?php echo esc_attr($mlsimportItemHowMany); ?>"/>
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); ?>"/>
1129 2076 </fieldset>
1130 2077
1131 2078 <fieldset class="mlsimport-fieldset mlsimport_auto_switch">
1132 2079 <?php esc_html_e('Enable Auto Update every hour?', 'mlsimport'); ?>
@@ -1131,22 +2078,25 @@
1131 2078 <fieldset class="mlsimport-fieldset mlsimport_auto_switch">
1132 2079 <?php esc_html_e('Enable Auto Update every hour?', 'mlsimport'); ?>
1133 2080 <label class="mlsimport_switch">
1134 2081 <input type="hidden" value="0" name="mlsimport_item_stat_cron">
1135 - <input type="checkbox" value="1" name="mlsimport_item_stat_cron"<?php if (intval($mlsimportItemStatCron) !== 0) echo esc_html(' checked'); ?>>
2082 + <input type="checkbox" class="mlsimport-import-checkbox" value="1" name="mlsimport_item_stat_cron"<?php if (intval($mlsimportItemStatCron) !== 0) echo esc_html(' checked'); ?>>
1136 2083 <span class="slider round"></span>
1137 2084 </label>
1138 2085 </fieldset>
1139 2086
1140 - <?php if ($mlsimportItemStatCron !== ''): ?>
1141 - <div id="mlsimport_item_status"></div>
1142 - <input class="button mlsimport_button" type="button" id="mlsimport-start_item"
1143 - data-post-number="<?php echo intval($foundItems); ?>"
1144 - data-post_id="<?php echo intval($postId); ?>" value="Start Import">
1145 - <input class="button mlsimport_button" type="button" id="mlsimport_stop_item"
1146 - data-post-number="<?php echo intval($foundItems); ?>"
1147 - data-post_id="<?php echo intval($postId); ?>" value="Stop Import">
1148 - <?php endif; ?>
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; ?>
1149 2099
1150 2100 <input type="hidden" id="mlsimport_item_actions" value="<?php echo esc_attr(wp_create_nonce("mlsimport_item_actions")); ?>"/>
1151 2101 <div class="mlsimport_param_wrapper"><h2><?php esc_html_e('Import Parameters', 'mlsimport'); ?></h2>
1152 2102
@@ -1159,9 +2109,11 @@
1159 2109 <?php esc_html_e('Title Format', 'mlsimport'); ?>
1160 2110 </label>
1161 2111
1162 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>
1163 - <input type="text" id="mlsimport_item_title_format" name="mlsimport_item_title_format" value="<?php echo '' !== $mlsimportItemTitleFormat ? trim(esc_html($mlsimportItemTitleFormat)) : esc_html('{Address},{City},{CountyOrParish},{PropertyType}'); ?>"/>
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}'); ?>"/>
1164 2116 </fieldset>
1165 2117
1166 2118 <?php
1167 2119 $mlsimportItemAgent = esc_html(get_post_meta($postId, 'mlsimport_item_agent', true));
@@ -1170,9 +2122,9 @@
1170 2122 <fieldset class="mlsimport-fieldset">
1171 2123 <label class="mlsimport-label" for="mlsimport_item_agent">
1172 2124 <?php esc_html_e('Select Agent', 'mlsimport'); ?>
1173 2125 </label>
1174 - <select class="mlsimport-select" name="mlsimport_item_agent" id="mlsimport_item_agent">
2126 + <select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_agent" id="mlsimport_item_agent">
1175 2127 <?php
1176 2128 $permitedTags = mlsimport_allowed_html_tags_content();
1177 2129 $selectAgent =$this->theme_importer->mlsimportSaasThemeImportSelectAgent($mlsimportItemAgent);
1178 2130 print wp_kses($selectAgent, $permitedTags);
@@ -1179,8 +2131,23 @@
1179 2131 ?>
1180 2132 </select>
1181 2133 </fieldset>
1182 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 +
1183 2150 <?php
1184 2151 $mlsimportItemPropertyStatus = esc_html(get_post_meta($postId, 'mlsimport_item_property_status', true));
1185 2152 if ('' === $mlsimportItemPropertyStatus) {
1186 2153 $mlsimportItemPropertyStatus = 'publish';
@@ -1190,9 +2157,9 @@
1190 2157 <fieldset class="mlsimport-fieldset">
1191 2158 <label class="mlsimport-label" for="mlsimport_item_property_status">
1192 2159 <?php esc_html_e('Select Property Status on import', 'mlsimport'); ?>
1193 2160 </label>
1194 - <select class="mlsimport-select" name="mlsimport_item_property_status" id="mlsimport_item_property_status">
2161 + <select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_property_status" id="mlsimport_item_property_status">
1195 2162 <?php foreach ($statusArray as $value): ?>
1196 2163 <option value="<?php echo esc_attr($value); ?>" <?php if ($value === $mlsimportItemPropertyStatus) echo esc_html('selected'); ?>>
1197 2164 <?php echo esc_html($value); ?>
1198 2165 </option>
@@ -1206,9 +2173,9 @@
1206 2173 <fieldset class="mlsimport-fieldset">
1207 2174 <label class="mlsimport-label" for="mlsimport_item_property_user">
1208 2175 <?php esc_html_e('User', 'mlsimport'); ?>
1209 2176 </label>
1210 - <select class="mlsimport-select" id="mlsimport_item_property_user" name="mlsimport_item_property_user">
2177 + <select class="mlsimport-select mlsimport-2025-select" id="mlsimport_item_property_user" name="mlsimport_item_property_user">
1211 2178 <?php
1212 2179 $selectUser = $this->theme_importer->mlsimportSaasThemeImportSelectUser($mlsimportItemPropertyUser);
1213 2180 print wp_kses($selectUser, $permitedTags);
1214 2181 ?>
@@ -1225,33 +2192,44 @@
1225 2192 <fieldset class="mlsimport-fieldset">
1226 2193 <label class="mlsimport-label">
1227 2194 <?php esc_html_e('Price Between', 'mlsimport'); ?>
1228 2195 </label>
1229 - <input type="text" class="mlsimport-select" id="mlsimport_item_min_price" name="mlsimport_item_min_price" value="<?php echo esc_attr($mlsimportItemMinPrice); ?>"> and
1230 - <input type="text" class="mlsimport-select" id="mlsimport_item_max_price" name="mlsimport_item_max_price" value="<?php echo esc_attr($mlsimportItemMaxPrice); ?>">
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); ?>">
1231 2198 </fieldset>
1232 2199
1233 2200 <?php
1234 - $options = get_option($this->plugin_name . '_admin_options');
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 );
1235 2214
1236 - $mlsId = '';
1237 - if (isset($options['mlsimport_mls_name'])) {
1238 - $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name']));
1239 - }
1240 -
1241 - if ($mlsId > 5000) {
1242 - $fieldImport['PropertyType']['multiple'] = 'no';
1243 - }
1244 -
2215 + // Render one fieldset per import parameter.
1245 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.
1246 2222 $nameCheck = strtolower('mlsimport_item_' . $key . '_check');
1247 2223 $name = strtolower('mlsimport_item_' . $key);
1248 2224
2225 + // Current saved value + select-all flag for this field.
1249 2226 $value = get_post_meta($postId, $name, true);
1250 2227 $valueCheck = get_post_meta($postId, $nameCheck, true);
2228 + // extraCity/extraCounty render as a toggle button, not a plain label.
1251 2229 $extraClass = '';
1252 2230 if ('extraCity' === $key || 'extraCounty' === $key) {
1253 - $extraClass = ' mlsimport_hidden_field_button';
2231 + $extraClass = ' mlsimport_hidden_field_button button mlsimport_button';
1254 2232 }
1255 2233 ?>
1256 2234 <fieldset class="mlsimport-fieldset">
1257 2235 <label class="mlsimport-label <?php echo esc_attr($extraClass); ?>" for="<?php echo esc_attr($name); ?>">
@@ -1261,33 +2239,43 @@
1261 2239 <div class="mlsimport-input-wrapper" style="display:none">
1262 2240 <?php endif; ?>
1263 2241 <p class="mlsimport-exp"><?php echo wp_kses_post($this->mlsimport_notes_for_mls($mlsimportMlsId, $name, $field['description'])); ?>
1264 2242 <?php
2243 + // Whether the "select all" checkbox is currently on.
1265 2244 $isCheckboxAdmin = 0;
1266 2245 if (1 === intval($valueCheck)) {
1267 2246 $isCheckboxAdmin = 1;
1268 2247 }
1269 2248
1270 - $selectAllNone = [
1271 - 'InternetAddressDisplayYN',
1272 - 'InternetEntireListingDisplayYN',
1273 - 'PostalCode',
1274 - 'ListAgentKey',
1275 - 'ListAgentMlsId',
1276 - 'ListOfficeKey',
1277 - 'ListOfficeMlsId',
1278 - 'StandardStatus',
1279 - 'StandardStatusDelete',
1280 - 'ListingId',
1281 - 'extraCity',
1282 - 'extraCounty',
1283 - 'Exclude_ListOfficeKey',
1284 - 'Exclude_ListOfficeMlsId',
1285 - 'Exclude_ListAgentKey',
1286 - 'Exclude_ListAgentMlsId',
1287 - ];
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 + ];
1288 2272
1289 - if ($mlsId > 5000) {
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) {
1290 2278 $selectAllNone[] = 'PropertyType';
1291 2279 }
1292 2280
1293 2281 if (!in_array($key, $selectAllNone)): ?>
@@ -1295,9 +2283,9 @@
1295 2283 esc_html_e('- Or Select All ', 'mlsimport');
1296 2284
1297 2285 ?>
1298 2286 <input type="hidden" name="<?php echo esc_attr($nameCheck); ?>" value="0"/>
1299 - <input type="checkbox" name="<?php echo esc_attr($nameCheck); ?>" value="1" <?php print esc_attr(checked($isCheckboxAdmin, 1, 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)); ?>/>
1300 2288 <?php endif; ?>
1301 2289 </p>
1302 2290
1303 2291 <?php
@@ -1304,8 +2292,9 @@
1304 2292 $permittedStatus = ['active', 'active under contract', 'coming soon', 'activeundercontract', 'comingsoon', 'pending'];
1305 2293
1306 2294 if ($field['type'] === 'select'): ?>
1307 2295 <?php
2296 + // Multi-select fields need the multiple attr + [] name.
1308 2297 $multiple = '';
1309 2298 if ('yes' === $field['multiple']) {
1310 2299 $multiple = 'multiple';
1311 2300 $name .= '[]';
@@ -1310,8 +2299,9 @@
1310 2299 $multiple = 'multiple';
1311 2300 $name .= '[]';
1312 2301 }
1313 2302
2303 + // Default StandardStatus to Active when nothing saved.
1314 2304 if ('StandardStatus' === $key && '' === $value) {
1315 2305 $value = ['Active'];
1316 2306 }
1317 2307
@@ -1316,31 +2306,70 @@
1316 2306 }
1317 2307
1318 2308
1319 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 +
1320 2318 // Additional conditions can be placed here.
1321 2319 ?>
1322 - <select class="mlsimport-select" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" <?php echo esc_attr($multiple); ?>>
1323 - <?php foreach ($field['values'] as $selectKey): ?>
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): ?>
1324 2326
1325 - <?php if ('' !== $selectKey): ?>
1326 - <option value="<?php echo esc_attr($selectKey); ?>"
1327 - <?php
1328 - if ($key === "StandardStatusDelete" && $value==null ) {
1329 -
1330 - print 'selected';
1331 - }
1332 - ?>
1333 - <?php if (is_array($value) ? in_array($selectKey, $value) : $selectKey === $value) echo 'selected'; ?>>
1334 - <?php echo esc_html($selectKey); ?>
1335 - </option>
1336 - <?php endif; ?>
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);
1337 2334
1338 - <?php endforeach; ?>
1339 - </select>
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 + }
1340 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 +
1341 2370 <?php elseif ($field['type'] === 'input'): ?>
1342 - <input type="text" class="mlsimport-select" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>">
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); ?>">
1343 2372 <?php endif; ?>
1344 2373 <?php if ('extraCity' === $key || 'extraCounty' === $key): ?>
1345 2374 </div>
1346 2375 <?php endif; ?>
@@ -1348,8 +2377,9 @@
1348 2377 <?php endforeach; ?>
1349 2378
1350 2379 </div>
1351 2380 <?php
2381 + // Return the buffered form markup.
1352 2382 return ob_get_clean();
1353 2383 }
1354 2384
1355 2385
@@ -1356,18 +2386,22 @@
1356 2386
1357 2387
1358 2388
1359 2389
2390 + // Placeholder hook target for injecting additional Import Task fields (no-op).
1360 2391 public function mlsimport_add_extra_fields() {
1361 2392 }
1362 2393
1363 2394 /**
2395 + * Per-field help text override, keyed by MLS + meta field.
1364 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.
1365 2399 *
1366 - *
1367 - *
1368 - *
1369 - *
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
1370 2404 */
1371 2405 function mlsimport_notes_for_mls( $mlsimport_mls_id, $name, $description ) {
1372 2406 // 111 - Rae Edmonton
1373 2407
@@ -1379,15 +2413,18 @@
1379 2413 }
1380 2414
1381 2415
1382 2416 /**
2417 + * Return the "last checked" timestamp for an Import Task, seeding it if unset.
1383 2418 *
1384 - *
1385 - * Get Last date
2419 + * @param int $item_id Import Task post id.
2420 + * @return string A 'Y-m-d\TH:i' timestamp.
1386 2421 */
1387 2422 public function mlsimport_saas_get_last_date( $item_id ) {
2423 + // Stored watermark used as the modification-time filter for syncs.
1388 2424 $last_date = get_post_meta( $item_id, 'mlsimport_last_date', true );
1389 2425
2426 + // First run: initialize it.
1390 2427 if ( '' === $last_date ) {
1391 2428 $last_date = $this->mlsimport_saas_update_last_date( $item_id );
1392 2429 }
1393 2430 return $last_date;
@@ -1394,14 +2431,19 @@
1394 2431 }
1395 2432
1396 2433
1397 2434 /**
2435 + * Set the Import Task's "last checked" watermark to 2 hours ago and store it.
1398 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.
1399 2439 *
1400 - * Save Last date
2440 + * @param int $item_id Import Task post id.
2441 + * @return string The stored 'Y-m-d\TH:i' timestamp.
1401 2442 */
1402 2443 public function mlsimport_saas_update_last_date( $item_id ) {
1403 2444
2445 + // Current site time minus 2 hours, formatted as an ISO-ish local stamp.
1404 2446 $unix_time = current_time( 'timestamp', 0 ) - ( 2 * 60 * 60 );
1405 2447 print $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time );
1406 2448 update_post_meta( $item_id, 'mlsimport_last_date', $last_date_to_save );
1407 2449
@@ -1409,199 +2451,236 @@
1409 2451 }
1410 2452
1411 2453
1412 2454
1413 - /**
1414 - *
1415 - *
1416 - * Check if there are modified items in the last 2h
1417 - */
1418 - public function mlsimport_saas_start_cron_links_per_item( $item_id ) {
1419 2455
1420 - $last_date = $this->mlsimport_saas_get_last_date( $item_id );
1421 - print 'MLSitem id: '.$item_id.' - ';
1422 - esc_html_e('date to consider: ','mlsimport');
1423 - print esc_html($last_date ). '. ';
1424 2456
1425 - $mlsrequest = $this->mlsimport_make_listing_requests( $item_id, $last_date );
1426 -
1427 - $found_items = 0;
1428 - if ( isset( $mlsrequest['results'] ) ) {
1429 - $found_items = intval( $mlsrequest['results'] );
1430 - } else {
1431 - delete_transient( 'mlsimport_saas_token' );
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;
1432 2479 }
2480 + mlsimport_alert_resolve( $incident );
1433 2481
1434 - print esc_html__('We found ','mlsimport') .esc_html( $found_items ). ' listings.</br>' . PHP_EOL;
1435 -
1436 - $attachments_to_move = array();
1437 -
1438 - if ( $found_items > 0 ) {
1439 - $item_id_array = array(
1440 - 'item_id' => $item_id,
1441 - 'how_many' => 0,
1442 - 'max_number' => $found_items,
1443 - 'batch_counter' => 1,
1444 - );
1445 -
1446 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date );
1447 -
1448 - update_post_meta( $item_id, 'mlsimport_spawn_status_cron_job', 'started' );
1449 - update_post_meta( $item_id, 'mlsimport_cron_attach_to_move_' . $item_id, $attachments_to_move );
1450 -
1451 - // save last date for next run
1452 - $this->mlsimport_saas_update_last_date( $item_id );
1453 -
1454 - $attachments_to_send = array(
1455 - 'args' => array(
1456 - 'attachments_to_move' => $item_id,
1457 - 'item_id_array' => $item_id_array,
1458 - ),
1459 - );
1460 -
1461 - $this->mlsimport_background_process_per_item_cron_function( $attachments_to_send['args'] );
1462 -
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;
1463 2492 }
1464 - }
1465 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' ) );
1466 2499
1467 -
1468 -
1469 - /**
1470 - *
1471 - *
1472 - * Reconciliation log
1473 - */
1474 - public function mlsimport_saas_start_doing_reconciliation() {
1475 - global $mlsimport;
1476 - print 'start';
1477 - $listingKey_in_Local = $this->mlsimport_saas_get_all_meta_values( 'ListingKey' );
1478 -
1479 - if ( empty( $listingKey_in_Local ) ) {
1480 - return;
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
1481 2505 }
1482 -
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();
1483 2521
1484 - $mls_data = $this->mlsimport_saas_get_mls_reconciliation_data();
1485 - $listingKey_in_MLS = $mls_data['all_data'];
2522 + return (int) $result['found'];
2523 + }
1486 2524
1487 - if ( empty( $listingKey_in_MLS ) ) {
1488 - return;
1489 - }
1490 2525
1491 - $to_delete = 0;
1492 - $counter = 0;
1493 - foreach ( $listingKey_in_Local as $key => $item ) {
1494 - $listingkey = $item->meta_value;
1495 - $property_id = $item->iD;
1496 - ++$counter;
1497 2526
1498 - //print '</br>'.$counter. ' **************************</br>';
1499 -
1500 - if ( in_array( $listingkey, $listingKey_in_MLS ) ) {
1501 - print wp_kses_post('</br>'.$listingkey . ' IS FOUND');
1502 - } else {
1503 -
1504 - $keep = $mlsimport->admin->theme_importer->check_if_delete_when_status($property_id);
1505 2527
1506 - if(!$keep){
1507 - ++$to_delete;
1508 - print wp_kses_post('</br>' .$listingkey. ' ------------------------- NOT FOUND delete: '.$property_id.' /'.$post_status.'<-');
1509 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql( $property_id, $listingkey );
1510 - }else{
1511 - print wp_kses_post('</br>' .$listingkey. ' ------------------------- NOT FOUND BUT MARKED AS KEEP: '.$property_id.' /'.$post_status.'<-');
1512 - }
1513 2528
1514 - }
1515 2529
1516 - //print '</br> ************************** ';
1517 - }
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 +}
1518 2547
1519 - print esc_html(' to delete:' .$to_delete);
1520 - return;
1521 - }
1522 -
1523 -
1524 2548 /**
2549 + * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API.
1525 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.
1526 2555 *
1527 - * Requestq Reconciliation log
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.
1528 2558 */
1529 - public function mlsimport_saas_get_mls_reconciliation_data() {
2559 + public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) {
1530 2560
1531 - $arguments = array();
1532 - $answer = $this->theme_importer->globalApiRequestCurlSaas( 'reconciliation', $arguments, 'GET' );
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' );
1533 2564 return $answer;
1534 2565 }
1535 2566
1536 2567 /**
2568 + * Return all published posts' values for a given meta key, with their post ids.
1537 2569 *
1538 - *
1539 - * Reconciliation get local data
2570 + * @param string $key Meta key to fetch.
2571 + * @return array Rows of {meta_value, ID}.
1540 2572 */
1541 - public function mlsimport_saas_get_all_meta_values( $key ) {
1542 - global $wpdb;
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 + }
1543 2590
1544 - $result = $wpdb->get_results(
1545 - $wpdb->prepare(
1546 - "
1547 - SELECT DISTINCT pm.meta_value,p.iD FROM {$wpdb->postmeta} pm
1548 - LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1549 - WHERE pm.meta_key = %s
1550 - AND p.post_status = 'publish'",
1551 - $key
1552 - )
1553 - );
1554 2591
1555 - return $result;
1556 - }
1557 2592
1558 -
1559 -
1560 -
1561 -
1562 - /*
1563 - * Do api Listing Requests
2593 + /**
2594 + * Run a single listings request for an Import Task and return the API result.
1564 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.
1565 2600 *
1566 - *
1567 - *
1568 - * */
1569 - public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '' ) {
1570 - $options = get_option( $this->plugin_name . '_admin_options' );
1571 - $mls_id = '';
1572 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1573 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
1574 - }
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 );
1575 2611
1576 - $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top );
1577 -
1578 -
1579 - if (
1580 - $mls_id > 5000 && $mls_id < 6000 &&
1581 - ( ! isset( $arguments['property_type'] ) or
1582 - ( isset( $arguments['property_type'] ) && '' === $arguments['property_type'] ) or
1583 - ( isset( $arguments['property_type'][0] ) && '' === $arguments['property_type'][0] )
1584 - )
1585 - ) {
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'];
1586 2616 return array(
1587 2617 'success' => false,
1588 - 'type' => 'rapattoni',
1589 - 'message' => esc_html__( 'This MLS requires to have one item selected from "Property Action Category" dropdown', 'mlsimport' ),
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' ),
1590 2620 );
1591 2621 }
1592 2622
2623 + // Guard against an over-long query string (too many parameters selected).
1593 2624 $potential_leght = strlen( wp_json_encode( $arguments ) );
1594 2625 if ( $potential_leght > 1750 ) {
1595 2626 return array(
1596 2627 'success' => false,
1597 2628 'potential_leght' => $potential_leght,
1598 - 'message' => esc_html__( 'You have too many parameters selected. Split the import beween multiple MLS Import items: For ex : Import per County instead of selecting 10 cities or import listing between certain price range.', 'mlsimport' ),
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' ),
1599 2630 );
1600 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 + }
1601 2643
1602 - $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
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.
1603 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 +
1604 2683 return ( $answer );
1605 2684 }
1606 2685
1607 2686
@@ -1608,26 +2687,38 @@
1608 2687
1609 2688
1610 2689
1611 2690
1612 - /*
1613 - * Create Api query arguments
2691 + /**
2692 + * Assemble the RESO listings query arguments from an Import Task's meta.
1614 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).
1615 2700 *
1616 - *
1617 - *
1618 - *
1619 - * */
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 ) {
1620 2709
1621 - public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '' ) {
1622 -
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.
1623 2713 $options = get_option( $this->plugin_name . '_admin_options' );
1624 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1625 - $mls_id = intval( $options['mlsimport_mls_name'] );
1626 - } else {
2714 + $mls_id = mlsimport_task_mls_id( (int) $item_id );
2715 + if ( $mls_id <= 0 ) {
1627 2716 return '';
1628 2717 }
1629 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).
1630 2721 if ( isset( $options['mlsimport_theme_used'] ) ) {
1631 2722 $theme_id = intval( $options['mlsimport_theme_used'] );
1632 2723 } else {
1633 2724 return '';
@@ -1632,12 +2723,18 @@
1632 2723 } else {
1633 2724 return '';
1634 2725 }
1635 2726
2727 + // Base parameters every request carries.
1636 2728 $values = array();
1637 2729 $values['mls_id'] = $mls_id;
1638 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 + }
1639 2735
2736 + // Pagination (only when a page size was supplied).
1640 2737 if ( '' !== $top ) {
1641 2738 $values['top'] = $top;
1642 2739 $values['skip'] = intval( $skip );
1643 2740 }
@@ -1642,8 +2739,9 @@
1642 2739 $values['skip'] = intval( $skip );
1643 2740 }
1644 2741
1645 2742 // // add price
2743 + // Price range (only when both bounds are set).
1646 2744 $mlsimport_item_min_price = get_post_meta( $item_id, 'mlsimport_item_min_price', true );
1647 2745 $mlsimport_item_max_price = get_post_meta( $item_id, 'mlsimport_item_max_price', true );
1648 2746 if ( '' !== $mlsimport_item_min_price && '' !== $mlsimport_item_max_price ) {
1649 2747 $values['list_price_min'] = floatval( $mlsimport_item_min_price );
@@ -1655,16 +2753,22 @@
1655 2753
1656 2754 // add county
1657 2755 $values = $this->mls_import_return_multiple_param_value( 'countyorparish', $item_id, 'county_or_parish', $values );
1658 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 +
1659 2763 // add postal code
1660 2764 $values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values );
1661 2765
1662 2766 // add status
1663 2767
1664 - if ( 111 !== $mls_id ) { // edmonton check
1665 - $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values );
1666 - }
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 );
1667 2771
1668 2772 // add property_subtype
1669 2773 $values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values );
1670 2774
@@ -1670,17 +2774,8 @@
1670 2774
1671 2775 // add property_type
1672 2776 $values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values );
1673 2777
1674 - // rapattoni exception
1675 - if ( $mls_id > 5000 ) {
1676 - $values = $this->mls_import_saas_add_to_parms_input( 'PropertyType', $item_id, 'property_type', $values );
1677 - $temp = $values['property_type'];
1678 - $temp = str_replace( ' ', '', $temp );
1679 - $values['property_type'] = array();
1680 - $values['property_type'][] = $temp;
1681 - }
1682 -
1683 2778 // add internet_entirelisting_displayyn
1684 2779 $values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values );
1685 2780
1686 2781 // add internet_address_displayyn
@@ -1685,14 +2780,16 @@
1685 2780
1686 2781 // add internet_address_displayyn
1687 2782 $values = $this->mls_import_saas_add_to_parms_input( 'InternetAddressDisplayYN', $item_id, 'internet_address_displayyn', $values );
1688 2783
1689 - // add ListAgentKey
1690 - $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentKey', $item_id, 'list_agentkey', $values );
1691 - // add ListAgentKey
1692 - $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentMlsId', $item_id, 'list_agentmlsid', $values );
1693 - // add ListOfficeKey
1694 - $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeKey', $item_id, 'list_officekey', $values );
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 );
1695 2792 // add ListOfficeMlsId
1696 2793 $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeMlsId', $item_id, 'list_officemlsid', $values );
1697 2794
1698 2795 // add ListingId
@@ -1697,8 +2794,12 @@
1697 2794
1698 2795 // add ListingId
1699 2796 $values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values );
1700 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 +
1701 2802 //add Exclude_ListOfficeKey
1702 2803 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values );
1703 2804 // add Exclude_ListOfficeMlsId
1704 2805 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values );
@@ -1708,27 +2809,47 @@
1708 2809 //add Exclude_ListAgentKey
1709 2810 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentKey', $item_id, 'exclude_list_agentkey', $values );
1710 2811 // add Exclude_ListAgentMlsId
1711 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 );
1712 2815
1713 -
1714 2816
1715 - if ( '' !== $last_date ) {
1716 - $values['modification_time'] = $last_date;
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() );
1717 2827 }
1718 2828
1719 - return( $values );
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'];
1720 2835 }
1721 2836
1722 2837
1723 2838
1724 - /*
2839 + /**
2840 + * Copy a single scalar Import Task meta value into the arguments array.
1725 2841 *
1726 - * add input items to parameters array
2842 + * Reads mlsimport_item_<key> and, when non-empty, stores it under $new_name.
1727 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.
1728 2849 */
1729 -
1730 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.
1731 2852 $name = strtolower( 'mlsimport_item_' . $key );
1732 2853 $value = get_post_meta( $post_id, $name, true );
1733 2854 if ( '' !== $value ) {
1734 2855 $all_values[ $new_name ] = $value;
@@ -1737,15 +2858,24 @@
1737 2858 return $all_values;
1738 2859 }
1739 2860
1740 2861
1741 - /*
2862 + /**
2863 + * Copy a multi-value (list) Import Task meta value into the arguments array.
1742 2864 *
1743 - * add list items to parameters array
1744 - *
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.
1745 2875 */
1746 -
1747 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.
1748 2878 $name_check = strtolower( 'mlsimport_item_' . $key . '_check' );
1749 2879 $name = strtolower( 'mlsimport_item_' . $key );
1750 2880
1751 2881 $value = get_post_meta( $post_id, $name, true );
@@ -1790,8 +2920,9 @@
1790 2920 }
1791 2921 }
1792 2922 }
1793 2923
2924 + // Only include the list when "select all" is off and there is a value.
1794 2925 $value_check = get_post_meta( $post_id, $name_check, true );
1795 2926
1796 2927 if ( 0 === intval($value_check) && '' !== $value ) {
1797 2928 $all_values[ $new_name ] = $value;
@@ -1796,9 +2927,9 @@
1796 2927 if ( 0 === intval($value_check) && '' !== $value ) {
1797 2928 $all_values[ $new_name ] = $value;
1798 2929 }
1799 2930
1800 - // status exception
2931 + // status exception: always send status, regardless of the check flag.
1801 2932 if ( 'status' === $new_name ) {
1802 2933 $all_values[ $new_name ] = $value;
1803 2934 }
1804 2935
@@ -1806,23 +2937,27 @@
1806 2937 }
1807 2938
1808 2939
1809 2940
1810 - /*
2941 + /**
2942 + * Build the Import Task field definition list (labels, types, enum values).
1811 2943 *
1812 - * All Enums fiels to be used on MLS import Item
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.
1813 2949 *
1814 - *
1815 - *
1816 - *
1817 - *
1818 - *
1819 - * */
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 ) {
1820 2954
1821 - public function mlsimport_saas_return_mls_fields() {
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 );
1822 2958
1823 - $mlsimport_mls_metadata_mls_enums = get_option( 'mlsimport_mls_metadata_mls_enums', '' );
1824 -
2959 + // Warn the user when no metadata is available yet.
1825 2960 if ( '' === $mlsimport_mls_metadata_mls_enums ) {
1826 2961 ?>
1827 2962 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
1828 2963 <?php
@@ -1827,8 +2962,9 @@
1827 2962 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
1828 2963 <?php
1829 2964 }
1830 2965
2966 + // Decode and reach into the enum container.
1831 2967 $metadata_api_call_full = json_decode( $mlsimport_mls_metadata_mls_enums, true );
1832 2968
1833 2969 if ( isset( $metadata_api_call_full['global_array'] ) ) {
1834 2970 $metadata_api_call = $metadata_api_call_full['global_array'];
@@ -1833,8 +2969,9 @@
1833 2969 if ( isset( $metadata_api_call_full['global_array'] ) ) {
1834 2970 $metadata_api_call = $metadata_api_call_full['global_array'];
1835 2971 }
1836 2972
2973 + // Extract each enum list as a flat array of option keys (empty if absent).
1837 2974 $city_array = array();
1838 2975 if ( isset( $metadata_api_call['PropertyEnums']['City'] ) && is_array( $metadata_api_call['PropertyEnums']['City'] ) ) {
1839 2976 $city_array = array_keys( $metadata_api_call['PropertyEnums']['City'] );
1840 2977 }
@@ -1853,42 +2990,33 @@
1853 2990 if ( isset( $metadata_api_call['PropertyEnums']['PropertySubType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertySubType'] ) ) {
1854 2991 $propertysubtype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertySubType'] );
1855 2992 }
1856 2993
1857 - $propertytype_array = array();
1858 - if ( isset( $metadata_api_call['PropertyEnums']['PropertyType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertyType'] ) ) {
1859 - $propertytype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertyType'] );
1860 - }
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 + }
1861 2998
2999 +
1862 3000 $standardstatus_array = array();
1863 - $standardstatus_delete_array=array();
1864 3001 if ( isset( $metadata_api_call['PropertyEnums']['StandardStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['StandardStatus'] ) ) {
1865 3002 $standardstatus_array = array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
1866 - $standardstatus_delete_array= array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
1867 3003 }
1868 3004
1869 3005 // if we do not have standart status
3006 + // Fall back to MlsStatus values when the MLS exposes no StandardStatus.
1870 3007 if ( empty( $standardstatus_array ) ) {
1871 3008 $standardstatus_array = $mlsstatus_array;
1872 - $standardstatus_delete_array = $mlsstatus_array;
1873 -
1874 3009 }
1875 3010
1876 -
1877 - $permited_status=array('active','active under contract','coming soon','activeundercontract','comingsoon','pending');
1878 - $permited_status_lower = array_map('strtolower', $permited_status);
1879 -
1880 - // Filter out permitted statuses from array1 values
1881 - // $standardstatus_delete_array = array_filter($standardstatus_delete_array, function ($value) use ($permited_status_lower) {
1882 - // return !in_array(strtolower($value), $permited_status_lower);
1883 -// });
1884 -
1885 3011
1886 3012
1887 3013
3014 + // Free-text "extra" inputs render empty; they hold comma-separated values.
1888 3015 $extracounty_values = '';
1889 3016 $extracity_values = '';
1890 3017
3018 + // Ordered field definitions consumed by the Import Task metabox renderer.
1891 3019 $field_import = array(
1892 3020 'City' => array(
1893 3021 'label' => esc_html__( 'Select cities', 'mlsimport' ),
1894 3022 'description' => esc_html__( 'Select the cities from where we will import data.', 'mlsimport' ),
@@ -1913,19 +3041,33 @@
1913 3041 'values' => $county_array,
1914 3042 'show_extra_field' => true,
1915 3043 ),
1916 3044
1917 - 'extraCounty' => array(
1918 - 'label' => esc_html__( 'Add extra Counties', 'mlsimport' ),
1919 - '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' ),
1920 - 'type' => 'input',
1921 - 'multiple' => 'no',
1922 - 'values' => $extracounty_values,
1923 - ),
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 + ),
1924 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 +
1925 3067 'PostalCode' => array(
1926 3068 'label' => esc_html__( 'Select Postal Code', 'mlsimport' ),
1927 - 'description' => esc_html__( 'Select the PostalCode from where to import listings. Works with only one PostalCode.', 'mlsimport' ),
3069 + 'description' => esc_html__( 'Enter one or more postal codes to import listings from, separated by commas (e.g. 12345, 23456).', 'mlsimport' ),
1928 3070 'type' => 'input',
1929 3071 'multiple' => 'no',
1930 3072 ),
1931 3073
@@ -1949,14 +3091,14 @@
1949 3091 'type' => 'select',
1950 3092 'multiple' => 'yes',
1951 3093 'values' => $standardstatus_array,
1952 3094 ),
1953 - 'StandardStatusDelete' => array(
1954 - 'label' => esc_html__( 'Delete Statuses', 'mlsimport' ),
1955 - 'description' => __( 'Properties with these statuses will be deleted from your website after they are removed from MLS database. If you edit the field after importing, the changes will NOT apply to listings that have already been imported.', 'mlsimport' ),
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' ),
1956 3098 'type' => 'select',
1957 3099 'multiple' => 'yes',
1958 - 'values' => $standardstatus_delete_array,
3100 + 'values' => $standardstatus_array,
1959 3101 ),
1960 3102
1961 3103 'InternetEntireListingDisplayYN' => array(
1962 3104 'label' => esc_html__( 'Internet Entire Listing Display ', 'mlsimport'),
@@ -1983,20 +3125,26 @@
1983 3125 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
1984 3126 'type' => 'input',
1985 3127 'multiple' => 'no',
1986 3128 ),
1987 - 'ListAgentMlsId' => array(
1988 - 'label' => esc_html__( 'ListAgentMlsId', 'mlsimport' ),
1989 - 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
1990 - 'type' => 'input',
1991 - 'multiple' => 'no',
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',
1992 3146 ),
1993 - 'ListOfficeKey' => array(
1994 - 'label' => esc_html__( 'ListOfficeKey', 'mlsimport' ),
1995 - 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport'),
1996 - 'type' => 'input',
1997 - 'multiple' => 'no',
1998 - ),
1999 3147 'ListOfficeMlsId' => array(
2000 3148 'label' => esc_html__( 'ListOfficeMlsId', 'mlsimport' ),
2001 3149 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport' ),
2002 3150 'type' => 'input',
@@ -2007,8 +3155,14 @@
2007 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'),
2008 3156 'type' => 'input',
2009 3157 'multiple' => 'no',
2010 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 + ),
2011 3165 'Exclude_ListOfficeMlsId' => array(
2012 3166 'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ),
2013 3167 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ),
2014 3168 'type' => 'input',
@@ -2033,8 +3187,14 @@
2033 3187 'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentKey ', 'mlsimport'),
2034 3188 'type' => 'input',
2035 3189 'multiple' => 'no',
2036 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 + ),
2037 3197
2038 3198
2039 3199 );
2040 3200 return $field_import;
@@ -2046,268 +3206,280 @@
2046 3206
2047 3207
2048 3208
2049 3209 /**
3210 + * AJAX: kick off a manual import for one Import Task.
2050 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}.
2051 3216 *
2052 - * AYsnc Test
3217 + * @return void Emits JSON.
2053 3218 */
2054 3219 public function mlsimport_move_files_per_item() {
2055 3220 check_ajax_referer( 'mlsimport_item_actions', 'security' );
2056 - $post_id = 0;
2057 - $how_many = 0;
2058 - $max_number = 0;
2059 - if(isset( $_POST['post_id'] )){
2060 - $post_id = intval( $_POST['post_id'] );
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 );
2061 3230 }
2062 - if(isset( $_POST['how_many'] )){
2063 - $how_many = intval( $_POST['how_many'] );
2064 - }
2065 - if(isset( $_POST['post_number'] )){
2066 - $max_number = intval( $_POST['post_number'] );
2067 - }
2068 -
2069 - update_option( 'mlsimport_force_stop_' . $post_id, 'no', false );
2070 3231
2071 - $item_id_array = array(
2072 - 'item_id' => $post_id,
2073 - 'how_many' => $how_many,
2074 - 'max_number' => $max_number,
2075 - 'batch_counter' => 1,
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 + )
2076 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 + }
2077 3252
2078 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, '' );
2079 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array );
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 );
2080 3255
2081 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, $attachments_to_move );
3256 + $this->mlsimport_enqueue_import_worker( (string) $start['run_id'] );
2082 3257
2083 - $attachments_to_send = array(
2084 - 'args' => array(
2085 - 'attachments_to_move' => $post_id,
2086 - 'item_id_array' => $item_id_array,
2087 - ),
3258 + wp_send_json(
3259 + array(
3260 + 'success' => true,
3261 + 'run_id' => (string) $start['run_id'],
3262 + )
2088 3263 );
2089 -
2090 - mlsimport_saas_single_write_import_custom_logs( 'Preparing the import. Please hold on.' . PHP_EOL );
2091 - mlsimport_debuglogs_per_plugin( 'Preparing the import. Please hold on.' . PHP_EOL );
2092 -
2093 - update_post_meta( $post_id, 'mlsimport_spawn_status', 'started' );
2094 - as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send );
2095 - spawn_cron();
2096 -
2097 - unset( $attachments_to_send );
2098 - die();
2099 3264 }
2100 3265
2101 3266 /**
3267 + * Queue the background import worker for an accepted Import Run.
2102 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.
2103 3278 *
2104 - * Processing Cron
3279 + * @param string $run_id Accepted run identity to hand to the worker.
3280 + * @return void
2105 3281 */
2106 - public function mlsimport_background_process_per_item_cron_function( $input_arg ) {
2107 - $log = 'In cron processing function ->' . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL;
2108 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2109 - global $mlsimport;
2110 -
2111 - // Get from MLS Import it the big argument arrat
2112 - $attachments_to_move = get_post_meta( $input_arg['item_id_array']['item_id'], 'mlsimport_cron_attach_to_move_' . $input_arg['item_id_array']['item_id'], true );
2113 -
2114 - $log = 'In processing function mlsimport_cron_attach_to_move_ ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
2115 - mlsimport_saas_single_write_import_custom_logs( $log );
2116 -
2117 -
2118 - // foreach($input_arg['attachments_to_move'] as $key=>$import_link){
2119 - foreach ( $attachments_to_move as $key => $import_arguments ) {
2120 - $GLOBALS['wp_object_cache']->delete( 'mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options' );
2121 -
2122 - $log = PHP_EOL . ' Cron Parsing importing batch : ' . $key . ' = ' . wp_json_encode( $import_arguments ) . PHP_EOL;
2123 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2124 -
2125 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
2126 -
2127 - $mlsimport->admin->theme_importer->mlsimportSaasCronParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key );
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 );
2128 3300 }
2129 3301
2130 - mlsimport_saas_single_write_import_custom_logs( 'CRON JOB Import Completed ' . PHP_EOL );
2131 - mlsimport_debuglogs_per_plugin( 'CRON JOB Import Completed ' . PHP_EOL );
2132 - update_post_meta( $input_arg['item_id_array']['item_id'], 'mlsimport_spawn_status', 'completed' );
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();
2133 3309 }
2134 3310
2135 3311 /**
3312 + * Return the adapter configuration error that blocks Stored mode imports.
2136 3313 *
2137 - *
2138 - * Generate import Requests per item
3314 + * @return string Empty when a supported adapter was composed.
2139 3315 */
2140 - public function mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date = '' ) {
2141 - $import_step = 25;
3316 + public function mlsimport_stored_listing_configuration_error(): string {
3317 + return $this->stored_listing_configuration_error;
3318 + }
2142 3319
2143 - $prop_id = $item_id_array['item_id'];
2144 - $max_found = $item_id_array['max_number'];
2145 - $how_many = $item_id_array['how_many'];
2146 - if ( 0=== intval($how_many) ) {
2147 - $how_many = $max_found;
2148 - }
2149 - if ( $how_many > $max_found ) {
2150 - $how_many = $max_found;
2151 - }
2152 3320
2153 - $search_url_step = '';
2154 - $urls_array = array();
2155 3321
2156 - $skip = 0;
2157 - if ( $how_many > 10000 ) {
2158 - $how_many = 10000;
2159 - }
2160 3322
2161 - if ( $how_many < $import_step ) {
2162 - $import_step = $how_many;
2163 - }
2164 3323
2165 - while ( $skip < $how_many ) {
2166 - $search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $import_step );
2167 - $skip = $skip + $import_step;
2168 - $urls_array[] = $search_url_step;
2169 - }
2170 - return $urls_array;
2171 - }
2172 3324
2173 3325
2174 3326
2175 3327
2176 3328
3329 +
3330 +
2177 3331 /**
2178 - * Process Async function
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
2179 3339 */
2180 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 + }
2181 3346
2182 - $mlsimportItemId = $input_arg['item_id_array']['item_id'];
2183 - $log_prefix = 'In processing function - Item ID: ' . $mlsimportItemId . ' -> ';
2184 - mlsimport_saas_single_write_import_custom_logs( $log_prefix . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL );
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 + );
2185 3373
2186 - //ini_set('memory_limit', '256M');
2187 - //ini_set('max_execution_time', 0); // Unlimited execution time
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 );
2188 3386
2189 - // Get from MLS Import the big argument array only once
2190 - $attachments_to_move = get_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId, true );
2191 -
2192 - // Retrieve all meta data in one go to reduce database queries
2193 - $mlsimport_item_option_data = array(
2194 - 'mlsimport_item_standardstatus' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatus', true ),
2195 - 'mlsimport_item_standardstatusdelete' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatusdelete', true ),
2196 - 'mlsimport_item_property_user' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_user', true ),
2197 - 'mlsimport_item_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_agent', true ),
2198 - 'mlsimport_item_property_status' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_status', true ),
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'
2199 3406 );
3407 + gc_collect_cycles();
3408 + }
2200 3409
2201 - $total_batches = count( $attachments_to_move );
2202 3410
2203 - // removed because $this
2204 - global $mlsimport;
2205 3411
2206 - $log = 'In processing function $attachments_to_move ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
2207 - mlsimport_saas_single_write_import_custom_logs( $log );
2208 - // mlsimport_debuglogs_per_plugin($log);
2209 - print esc_html($log);
2210 3412
2211 - foreach ( $attachments_to_move as $key => $import_arguments ) {
2212 - // reconsider use
2213 - // $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
2214 - $status = get_option( 'mlsimport_force_stop_' . $mlsimportItemId );
2215 - if ( 'no' === $status ) {
2216 - // reconsider use
2217 - // wp_cache_flush();
2218 - $mem_usage = memory_get_usage( true );
2219 - $mem_usage_show = round( $mem_usage / 1048576, 2 );
2220 3413
2221 -
2222 3414
2223 - mlsimport_saas_single_write_import_custom_logs( $log );
2224 - $log = 'Parsing import batch: ' . ( $key + 1 ) . ' of ' . $total_batches . '. Memory used: ' . $mem_usage_show . ' MB.' . PHP_EOL;
2225 3415
2226 - // Combine logs and reduce function calls
2227 - mlsimport_saas_single_write_import_custom_logs( $log );
2228 - mlsimport_debuglogs_per_plugin( $log );
2229 - print esc_html($log);
2230 3416
2231 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
2232 3417
2233 - $mlsimport->admin->theme_importer->mlsimportSaasParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key, $mlsimport_item_option_data );
2234 - } else {
2235 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
2236 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
2237 - mlsimport_saas_single_write_import_custom_logs( PHP_EOL . 'Parsing importing link FORCE STOP : ' );
2238 - mlsimport_debuglogs_per_plugin( 'Parsing importing link FORCE STOP : ' );
2239 - break; // Exit the loop if forced to stop
2240 - }
2241 - }
2242 -
2243 - mlsimport_saas_single_write_import_custom_logs( 'Import Completed ' . PHP_EOL );
2244 - mlsimport_debuglogs_per_plugin( 'Import Completed ' . PHP_EOL );
2245 -
2246 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
2247 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
2248 -
2249 - unset( $attachments_to_move );
2250 - unset( $api_call_array );
2251 - unset( $input_arg );
2252 - unset( $log );
2253 - unset( $log2 );
2254 - }
2255 -
2256 -
2257 -
2258 -
2259 -
2260 3418 /**
3419 + * AJAX: poll import status/logs for a task (drives the progress UI).
2261 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).
2262 3424 *
2263 - *
2264 - * update log function
3425 + * @return void Emits JSON then dies.
2265 3426 */
2266 3427 public function mlsimport_logger_per_item() {
2267 - check_ajax_referer( 'mlsimport_item_actions', 'security' );
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 + }
2268 3439 $post_id=0;
2269 3440 if(isset($_POST['post_id'] )){
2270 3441 $post_id = intval( $_POST['post_id'] );
2271 3442 }
2272 3443
2273 - $status = get_post_meta( $post_id, 'mlsimport_spawn_status', true );
2274 - $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
2275 - $logs = file_get_contents( $path );
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 + }
2276 3457
2277 - $force_status = intval( get_post_meta( $post_id, 'mlsimport_force_stop', true ) );
2278 - $force_status = get_option( 'mlsimport_force_stop_' . $post_id );
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 ) : '';
2279 3463
2280 - if ( 'no' !== $force_status ) {
2281 - echo wp_json_encode(
2282 - array(
2283 - 'is_done' => 'done',
2284 - 'status' => $status,
2285 - 'logs' => $logs,
2286 - )
2287 - );
2288 - die();
2289 - }
2290 -
2291 - if ( '' === $status || 'completed' === $status ) {
2292 - echo wp_json_encode(
2293 - array(
2294 - 'is_done' => 'done',
2295 - 'status' => $status,
2296 - 'logs' => $logs,
2297 - )
2298 - );
2299 - } else {
2300 - // return from log
2301 - echo wp_json_encode(
2302 - array(
2303 - 'is_done' => 'wip',
2304 - 'status' => $status,
2305 - 'logs' => $logs,
2306 - )
2307 - );
2308 - }
2309 - die();
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 + );
2310 3482 }
2311 3483
2312 3484
2313 3485
@@ -2314,53 +3486,105 @@
2314 3486
2315 3487
2316 3488
2317 3489 /**
3490 + * AJAX: request a force-stop of a running import for one task.
2318 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.
2319 3494 *
2320 - *
2321 - *
2322 - * Force Stop Import
3495 + * @return void Emits JSON success.
2323 3496 */
2324 3497 public function mlsimport_stop_import_per_item() {
3498 +
3499 +
3500 + // CSRF + read the task id.
2325 3501 check_ajax_referer( 'mlsimport_item_actions', 'security' );
2326 3502 $post_id=0;
2327 3503 if(isset($_POST['post_id'] )){
2328 - $post_id = intval( $_POST['post_id'] );
3504 + $post_id = intval( $_POST['post_id'] );
2329 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.
2330 3513 update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false );
2331 - mlsimport_saas_single_write_import_custom_logs( 'Stopeed for ' . $post_id . PHP_EOL );
2332 - mlsimport_debuglogs_per_plugin( 'Stopeed for ' . $post_id . PHP_EOL );
2333 -
2334 - die();
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'] ) );
2335 3517 }
2336 3518
2337 3519
2338 3520
2339 - /*
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.
2340 3524 *
2341 - * Get MLS Metadata
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.
2342 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).
2343 3538 *
2344 - *
2345 - *
2346 - **/
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 + }
2347 3547
2348 - public function mlsimport_saas_get_metadata_function() {
2349 - check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' );
2350 - $theme_Start = new ThemeImport();
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 );
2351 3550
2352 - $values = array();
2353 - $options = get_option( $this->plugin_name . '_admin_options' );
2354 - $url = 'clients?theme_id=' . intval( $options['mlsimport_theme_used'] );
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 + }
2355 3565
2356 - $answer = $theme_Start::globalApiRequestSaas( $url, $values, 'GET' );
3566 + // One shared gather core, scoped to the resolved connection.
3567 + $gather = mlsimport_gather_connection_metadata( $mls_id );
2357 3568
2358 - update_option( 'mlsimport_mls_metadata_populated', 'yes' );
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 + }
2359 3579
2360 - update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] );
2361 - update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] );
2362 - update_option( 'mlsimport_mls_metadata_mls_enums', $answer['mls_data']['mls_meta_enums'] );
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'] ) );
2363 3587 }
2364 3588
2365 3589
2366 3590
@@ -2373,17 +3597,24 @@
2373 3597
2374 3598
2375 3599
2376 3600 /**
3601 + * Append a timestamped message to the cron log file.
2377 3602 *
3603 + * Arrays are JSON-encoded; ensures the WP filesystem is initialized before
3604 + * writing (append + exclusive lock).
2378 3605 *
2379 - * write debug logs
3606 + * @param string|array $message Message to log.
3607 + * @return void
2380 3608 */
2381 3609 public function mlsimport_debuglog_cron( $message ) {
3610 + // Encode arrays for readability.
2382 3611 if ( is_array( $message ) ) {
2383 3612 $message = wp_json_encode( $message );
2384 3613 }
3614 + // Prefix with a human-readable timestamp.
2385 3615 $message = date( 'F j, Y, g:i a' ) . ' -> ' . $message;
3616 + // Ensure WP_Filesystem is available (harmless if already set up).
2386 3617 global $wp_filesystem;
2387 3618 if ( empty( $wp_filesystem ) ) {
2388 3619 require_once ABSPATH . '/wp-admin/includes/file.php';
2389 3620 WP_Filesystem();
@@ -2388,8 +3619,9 @@
2388 3619 require_once ABSPATH . '/wp-admin/includes/file.php';
2389 3620 WP_Filesystem();
2390 3621 }
2391 3622
3623 + // Append to the cron log with an exclusive lock.
2392 3624 $path = WP_PLUGIN_DIR . '/mlsimport/logs/cron_logs.log';
2393 3625
2394 3626 file_put_contents( $path, $message, FILE_APPEND | LOCK_EX );
2395 3627 }